From 40a9961edff9f1167eeab75e0fa0a534c991e5d0 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Thu, 12 Mar 2026 13:04:35 +0000 Subject: [PATCH 01/21] feat: add credential provider chain concept --- cloudsmith_cli/cli/commands/whoami.py | 32 +- cloudsmith_cli/cli/decorators.py | 108 +++++- cloudsmith_cli/cli/tests/conftest.py | 5 +- cloudsmith_cli/cli/tests/test_webserver.py | 6 +- cloudsmith_cli/cli/webserver.py | 11 +- cloudsmith_cli/core/api/init.py | 74 +--- cloudsmith_cli/core/credentials/__init__.py | 100 ++++++ .../core/credentials/providers/__init__.py | 9 + .../core/credentials/providers/cli_flag.py | 22 ++ .../credentials/providers/keyring_provider.py | 54 +++ cloudsmith_cli/core/credentials/session.py | 63 ++++ .../core/tests/test_cli_flag_provider.py | 34 ++ .../core/tests/test_credential_context.py | 14 + .../tests/test_credential_provider_chain.py | 80 +++++ cloudsmith_cli/core/tests/test_init.py | 327 ++---------------- .../core/tests/test_keyring_provider.py | 81 +++++ cloudsmith_cli/core/tests/test_rest.py | 43 +-- 17 files changed, 637 insertions(+), 426 deletions(-) create mode 100644 cloudsmith_cli/core/credentials/__init__.py create mode 100644 cloudsmith_cli/core/credentials/providers/__init__.py create mode 100644 cloudsmith_cli/core/credentials/providers/cli_flag.py create mode 100644 cloudsmith_cli/core/credentials/providers/keyring_provider.py create mode 100644 cloudsmith_cli/core/credentials/session.py create mode 100644 cloudsmith_cli/core/tests/test_cli_flag_provider.py create mode 100644 cloudsmith_cli/core/tests/test_credential_context.py create mode 100644 cloudsmith_cli/core/tests/test_credential_provider_chain.py create mode 100644 cloudsmith_cli/core/tests/test_keyring_provider.py diff --git a/cloudsmith_cli/cli/commands/whoami.py b/cloudsmith_cli/cli/commands/whoami.py index ffbf9842..59fa31c2 100644 --- a/cloudsmith_cli/cli/commands/whoami.py +++ b/cloudsmith_cli/cli/commands/whoami.py @@ -1,14 +1,11 @@ """CLI/Commands - Retrieve authentication status.""" -import os - import click from ...core import keyring from ...core.api.exceptions import ApiException from ...core.api.user import get_token_metadata, get_user_brief from .. import decorators, utils -from ..config import CredentialsReader from ..exceptions import handle_api_exceptions from .main import main @@ -26,26 +23,17 @@ def _get_active_method(api_config): def _get_api_key_source(opts): """Determine where the API key was loaded from. - Checks in priority order matching actual resolution: - CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini. + Uses the credential provider chain result attached by initialise_api. """ - if not opts.api_key: - return {"configured": False, "source": None, "source_key": None} - - env_key = os.environ.get("CLOUDSMITH_API_KEY") - - # If env var is set but differs from the resolved key, CLI flag won - if env_key and opts.api_key != env_key: - source, key = "CLI --api-key flag", "cli_flag" - elif env_key: - suffix = env_key[-4:] - source, key = f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", "env_var" - elif creds := CredentialsReader.find_existing_files(): - source, key = f"credentials.ini ({creds[0]})", "credentials_file" - else: - source, key = "CLI --api-key flag", "cli_flag" - - return {"configured": True, "source": source, "source_key": key} + credential = getattr(opts, "credential", None) + if credential: + return { + "configured": True, + "source": credential.source_detail or credential.source_name, + "source_key": credential.source_name, + } + + return {"configured": False, "source": None, "source_key": None} def _get_sso_status(api_host): diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index ba75e236..06ce7836 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -7,6 +7,8 @@ from cloudsmith_cli.cli import validators from ..core.api.init import initialise_api as _initialise_api +from ..core.credentials import CredentialContext, CredentialProviderChain +from ..core.credentials.session import create_session as _create_session from ..core.mcp import server from . import config, utils @@ -20,6 +22,14 @@ def report_retry(seconds, context=None): ) +def _pop_boolean_flag(kwargs, name, invert=False): + """Pop a boolean flag from kwargs, optionally inverting it.""" + value = kwargs.pop(name) + if value is not None and invert: + value = not value + return value + + def common_package_action_options(f): """Add common options for package actions.""" @@ -214,15 +224,17 @@ def common_api_auth_options(f): def wrapper(ctx, *args, **kwargs): # pylint: disable=missing-docstring opts = config.get_or_create_options(ctx) - opts.api_key = kwargs.pop("api_key") + api_key = kwargs.pop("api_key") + if api_key: + opts.api_key = api_key kwargs["opts"] = opts return ctx.invoke(f, *args, **kwargs) return wrapper -def initialise_api(f): - """Initialise the Cloudsmith API for use.""" +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." @@ -252,6 +264,78 @@ def initialise_api(f): envvar="CLOUDSMITH_API_HEADERS", help="A CSV list of extra headers (key=value) to send to the API.", ) + @click.pass_context + @functools.wraps(f) + def wrapper(ctx, *args, **kwargs): + # pylint: disable=missing-docstring + opts = config.get_or_create_options(ctx) + opts.api_host = kwargs.pop("api_host") + opts.api_proxy = kwargs.pop("api_proxy") + opts.api_ssl_verify = _pop_boolean_flag( + kwargs, "without_api_ssl_verify", invert=True + ) + opts.api_user_agent = kwargs.pop("api_user_agent") + opts.api_headers = kwargs.pop("api_headers") + + opts.session = _create_session( + proxy=opts.api_proxy, + ssl_verify=opts.api_ssl_verify, + user_agent=opts.api_user_agent, + headers=opts.api_headers, + ) + + kwargs["opts"] = opts + return ctx.invoke(f, *args, **kwargs) + + return wrapper + + +def resolve_credentials(f): + """Resolve credentials via the provider chain. Depends on initialise_session.""" + + @click.pass_context + @functools.wraps(f) + def wrapper(ctx, *args, **kwargs): + # pylint: disable=missing-docstring + opts = config.get_or_create_options(ctx) + + context = CredentialContext( + session=opts.session, + api_key=opts.api_key, + api_host=opts.api_host or "https://api.cloudsmith.io", + creds_file_path=ctx.meta.get("creds_file"), + profile=ctx.meta.get("profile"), + debug=opts.debug, + ) + + chain = CredentialProviderChain() + credential = chain.resolve(context) + + if context.keyring_refresh_failed: + click.secho( + "An error occurred when attempting to refresh your SSO access token. " + "To refresh this session, run 'cloudsmith auth'", + fg="yellow", + err=True, + ) + if credential: + click.secho( + "Falling back to API key authentication.", + fg="yellow", + err=True, + ) + + opts.credential = credential + + kwargs["opts"] = opts + return ctx.invoke(f, *args, **kwargs) + + return initialise_session(wrapper) + + +def initialise_api(f): + """Initialise the Cloudsmith API for use. Depends on resolve_credentials.""" + @click.option( "-R", "--without-rate-limit", @@ -294,20 +378,8 @@ def initialise_api(f): @functools.wraps(f) def wrapper(ctx, *args, **kwargs): # pylint: disable=missing-docstring - def _set_boolean(name, invert=False): - value = kwargs.pop(name) - value = value if value is not None else None - if value is not None and invert: - value = not value - return value - opts = config.get_or_create_options(ctx) - opts.api_host = kwargs.pop("api_host") - opts.api_proxy = kwargs.pop("api_proxy") - opts.api_ssl_verify = _set_boolean("without_api_ssl_verify", invert=True) - opts.api_user_agent = kwargs.pop("api_user_agent") - opts.api_headers = kwargs.pop("api_headers") - opts.rate_limit = _set_boolean("without_rate_limit", invert=True) + opts.rate_limit = _pop_boolean_flag(kwargs, "without_rate_limit", invert=True) opts.rate_limit_warning = kwargs.pop("rate_limit_warning") opts.error_retry_max = kwargs.pop("error_retry_max") opts.error_retry_backoff = kwargs.pop("error_retry_backoff") @@ -320,7 +392,7 @@ def call_print_rate_limit_info_with_opts(rate_info): opts.api_config = _initialise_api( debug=opts.debug, host=opts.api_host, - key=opts.api_key, + credential=opts.credential, proxy=opts.api_proxy, ssl_verify=opts.api_ssl_verify, user_agent=opts.api_user_agent, @@ -336,7 +408,7 @@ def call_print_rate_limit_info_with_opts(rate_info): kwargs["opts"] = opts return ctx.invoke(f, *args, **kwargs) - return wrapper + return resolve_credentials(wrapper) def initialise_mcp(f): diff --git a/cloudsmith_cli/cli/tests/conftest.py b/cloudsmith_cli/cli/tests/conftest.py index c42f23f4..ad6262dd 100644 --- a/cloudsmith_cli/cli/tests/conftest.py +++ b/cloudsmith_cli/cli/tests/conftest.py @@ -5,6 +5,7 @@ from ...core.api.init import initialise_api from ...core.api.repos import create_repo, delete_repo +from ...core.credentials import CredentialResult from .utils import random_str @@ -51,7 +52,9 @@ def organization(): @pytest.fixture() def tmp_repository(organization, api_host, api_key): """Yield a temporary repository.""" - initialise_api(host=api_host, key=api_key) + initialise_api( + host=api_host, credential=CredentialResult(api_key=api_key, source_name="test") + ) repo_data = create_repo(organization, {"name": random_str()}) yield repo_data delete_repo(organization, repo_data["slug"]) diff --git a/cloudsmith_cli/cli/tests/test_webserver.py b/cloudsmith_cli/cli/tests/test_webserver.py index 538ad147..12ecdf77 100644 --- a/cloudsmith_cli/cli/tests/test_webserver.py +++ b/cloudsmith_cli/cli/tests/test_webserver.py @@ -40,7 +40,11 @@ def test_refresh_api_config_passes_sso_token(self): mock_init_api.assert_called_once() call_kwargs = mock_init_api.call_args.kwargs - assert call_kwargs.get("access_token") == "test_sso_token_123" + credential = call_kwargs.get("credential") + assert credential is not None + assert credential.api_key == "test_sso_token_123" + assert credential.auth_type == "bearer" + assert credential.source_name == "sso" class TestAuthenticationWebRequestHandlerKeyring: diff --git a/cloudsmith_cli/cli/webserver.py b/cloudsmith_cli/cli/webserver.py index eff5d523..3d2ffca9 100644 --- a/cloudsmith_cli/cli/webserver.py +++ b/cloudsmith_cli/cli/webserver.py @@ -9,6 +9,7 @@ from ..core.api.exceptions import ApiException from ..core.api.init import initialise_api +from ..core.credentials import CredentialResult from ..core.keyring import store_sso_tokens from .saml import exchange_2fa_token @@ -79,7 +80,15 @@ def refresh_api_config_after_auth(self): user_agent=getattr(self.api_opts, "user_agent", None), headers=getattr(self.api_opts, "headers", None), rate_limit=getattr(self.api_opts, "rate_limit", True), - access_token=self.sso_access_token, + credential=( + CredentialResult( + api_key=self.sso_access_token, + source_name="sso", + auth_type="bearer", + ) + if self.sso_access_token + else None + ), ) def finish_request(self, request, client_address): diff --git a/cloudsmith_cli/core/api/init.py b/cloudsmith_cli/core/api/init.py index b6a856bc..ecbc8f6f 100644 --- a/cloudsmith_cli/core/api/init.py +++ b/cloudsmith_cli/core/api/init.py @@ -6,16 +6,13 @@ import click import cloudsmith_api -from ...cli import saml -from .. import keyring from ..rest import RestClient -from .exceptions import ApiException def initialise_api( debug=False, host=None, - key=None, + credential=None, proxy=None, ssl_verify=True, user_agent=None, @@ -26,7 +23,6 @@ def initialise_api( error_retry_backoff=None, error_retry_codes=None, error_retry_cb=None, - access_token=None, ): """Initialise the cloudsmith_api.Configuration.""" # FIXME: pylint: disable=too-many-arguments @@ -45,65 +41,15 @@ def initialise_api( config.verify_ssl = ssl_verify config.client_side_validation = False - # Use directly provided access token (e.g. from SSO callback), - # or fall back to keyring lookup if enabled. - if not access_token: - access_token = keyring.get_access_token(config.host) - - if access_token: - auth_header = config.headers.get("Authorization") - - # overwrite auth header if empty or is basic auth without username or password - if not auth_header or auth_header == config.get_basic_auth_token(): - refresh_token = keyring.get_refresh_token(config.host) - - try: - if keyring.should_refresh_access_token(config.host): - new_access_token, new_refresh_token = saml.refresh_access_token( - config.host, - access_token, - refresh_token, - session=saml.create_configured_session(config), - ) - keyring.store_sso_tokens( - config.host, new_access_token, new_refresh_token - ) - # Use the new tokens - access_token = new_access_token - except ApiException: - keyring.update_refresh_attempted_at(config.host) - - click.secho( - "An error occurred when attempting to refresh your SSO access token. To refresh this session, run 'cloudsmith auth'", - fg="yellow", - err=True, - ) - - # Clear access_token to prevent using expired token - access_token = None - - # Fall back to API key auth if available - if key: - click.secho( - "Falling back to API key authentication.", - fg="yellow", - err=True, - ) - config.api_key["X-Api-Key"] = key - - # Only use SSO token if refresh didn't fail - if access_token: - config.headers["Authorization"] = "Bearer {access_token}".format( - access_token=access_token - ) - - if config.debug: - click.echo("SSO access token config value set") - elif key: - config.api_key["X-Api-Key"] = key - - if config.debug: - click.echo("User API key config value set") + if credential: + if credential.auth_type == "bearer": + config.headers["Authorization"] = f"Bearer {credential.api_key}" + if config.debug: + click.echo("SSO access token config value set") + else: + config.api_key["X-Api-Key"] = credential.api_key + if config.debug: + click.echo("User API key config value set") auth_header = headers and config.headers.get("Authorization") if auth_header and " " in auth_header: diff --git a/cloudsmith_cli/core/credentials/__init__.py b/cloudsmith_cli/core/credentials/__init__.py new file mode 100644 index 00000000..d55413ce --- /dev/null +++ b/cloudsmith_cli/core/credentials/__init__.py @@ -0,0 +1,100 @@ +"""Credential Provider Chain for Cloudsmith CLI. + +Implements an AWS SDK-style credential resolution chain that evaluates +credential sources sequentially and returns the first valid result. +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import requests + +logger = logging.getLogger(__name__) + + +@dataclass +class CredentialContext: + """Context passed to credential providers during resolution. + + All values are populated directly from Click options / ``opts``. + """ + + session: requests.Session | None = None + api_key: str | None = None + api_host: str = "https://api.cloudsmith.io" + creds_file_path: str | None = None + profile: str | None = None + debug: bool = False + keyring_refresh_failed: bool = False + + +@dataclass +class CredentialResult: + """Result from a successful credential resolution.""" + + api_key: str + source_name: str + source_detail: str | None = None + auth_type: str = "api_key" + + +class CredentialProvider(ABC): + """Base class for credential providers.""" + + name: str = "base" + + @abstractmethod + def resolve(self, context: CredentialContext) -> CredentialResult | None: + """Attempt to resolve credentials. Return CredentialResult or None.""" + + +class CredentialProviderChain: + """Evaluates credential providers in order, returning the first valid result. + + If no providers are given, uses the default chain: + Keyring → CLIFlag. + """ + + def __init__(self, providers: list[CredentialProvider] | None = None): + if providers is not None: + self.providers = providers + else: + from .providers import CLIFlagProvider, KeyringProvider + + self.providers = [ + KeyringProvider(), + CLIFlagProvider(), + ] + + def resolve(self, context: CredentialContext) -> CredentialResult | None: + """Evaluate each provider in order. Return the first successful result.""" + for provider in self.providers: + try: + result = provider.resolve(context) + if result is not None: + if context.debug: + logger.debug( + "Credentials resolved by %s: %s", + provider.name, + result.source_detail or result.source_name, + ) + return result + if context.debug: + logger.debug( + "Provider %s did not resolve credentials, trying next", + provider.name, + ) + except Exception: # pylint: disable=broad-exception-caught + # Intentionally broad - one provider failing shouldn't stop others + logger.debug( + "Provider %s raised an exception, skipping", + provider.name, + exc_info=True, + ) + continue + return None diff --git a/cloudsmith_cli/core/credentials/providers/__init__.py b/cloudsmith_cli/core/credentials/providers/__init__.py new file mode 100644 index 00000000..5482e397 --- /dev/null +++ b/cloudsmith_cli/core/credentials/providers/__init__.py @@ -0,0 +1,9 @@ +"""Credential providers for the Cloudsmith CLI.""" + +from .cli_flag import CLIFlagProvider +from .keyring_provider import KeyringProvider + +__all__ = [ + "CLIFlagProvider", + "KeyringProvider", +] diff --git a/cloudsmith_cli/core/credentials/providers/cli_flag.py b/cloudsmith_cli/core/credentials/providers/cli_flag.py new file mode 100644 index 00000000..99b55dcf --- /dev/null +++ b/cloudsmith_cli/core/credentials/providers/cli_flag.py @@ -0,0 +1,22 @@ +"""CLI flag credential provider.""" + +from __future__ import annotations + +from .. import CredentialContext, CredentialProvider, CredentialResult + + +class CLIFlagProvider(CredentialProvider): + """Resolves credentials from a CLI flag value passed via CredentialContext.""" + + name = "cli_flag" + + def resolve(self, context: CredentialContext) -> CredentialResult | None: + api_key = context.api_key + if api_key and api_key.strip(): + suffix = api_key.strip()[-4:] + return CredentialResult( + api_key=api_key.strip(), + source_name="cli_flag", + source_detail=f"--api-key flag, CLOUDSMITH_API_KEY, or credentials.ini (ends with ...{suffix})", + ) + return None diff --git a/cloudsmith_cli/core/credentials/providers/keyring_provider.py b/cloudsmith_cli/core/credentials/providers/keyring_provider.py new file mode 100644 index 00000000..c4f44e95 --- /dev/null +++ b/cloudsmith_cli/core/credentials/providers/keyring_provider.py @@ -0,0 +1,54 @@ +"""Keyring credential provider.""" + +from __future__ import annotations + +import logging + +from .. import CredentialContext, CredentialProvider, CredentialResult + +logger = logging.getLogger(__name__) + + +class KeyringProvider(CredentialProvider): + """Resolves credentials from SAML tokens stored in the system keyring.""" + + name = "keyring" + + def resolve(self, context: CredentialContext) -> CredentialResult | None: + from ....cli.saml import refresh_access_token + from ....core import keyring + + if not keyring.should_use_keyring(): + return None + + api_host = context.api_host + access_token = keyring.get_access_token(api_host) + + if not access_token: + return None + + try: + if keyring.should_refresh_access_token(api_host): + if not context.session: + return None + refresh_token = keyring.get_refresh_token(api_host) + new_access_token, new_refresh_token = refresh_access_token( + api_host, + access_token, + refresh_token, + session=context.session, + ) + keyring.store_sso_tokens(api_host, new_access_token, new_refresh_token) + access_token = new_access_token + except Exception: # pylint: disable=broad-exception-caught + keyring.update_refresh_attempted_at(api_host) + context.keyring_refresh_failed = True + logger.debug("Failed to refresh SAML token", exc_info=True) + return None + + return CredentialResult( + api_key=access_token, + source_name="keyring", + source_detail="SAML token from system keyring", + auth_type="bearer", + ) diff --git a/cloudsmith_cli/core/credentials/session.py b/cloudsmith_cli/core/credentials/session.py new file mode 100644 index 00000000..9e314efb --- /dev/null +++ b/cloudsmith_cli/core/credentials/session.py @@ -0,0 +1,63 @@ +"""HTTP session factory with networking configuration and retry support.""" + +from __future__ import annotations + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +#: Default retry policy: retry on connection errors, 429, and 5xx responses +#: with exponential back-off (1s, 2s, 4s). +DEFAULT_RETRY = Retry( + total=3, + backoff_factor=1, + status_forcelist=(429, 500, 502, 503, 504), + allowed_methods=("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"), + raise_on_status=False, +) + + +def create_session( + proxy: str | None = None, + ssl_verify: bool = True, + user_agent: str | None = None, + headers: dict | None = None, + api_key: str | None = None, + retry: Retry | None = DEFAULT_RETRY, +) -> requests.Session: + """Create a requests session with networking, auth, and retry configuration. + + Args: + proxy: HTTP/HTTPS proxy URL. + ssl_verify: Whether to verify SSL certificates. + user_agent: Custom User-Agent header value. + headers: Additional headers to include in every request. + api_key: If provided, set as a Bearer token in the Authorization header. + retry: urllib3 Retry configuration. Defaults to :data:`DEFAULT_RETRY`. + Pass ``None`` to disable automatic retries. + + Returns: + A configured :class:`requests.Session`. + """ + session = requests.Session() + + if retry is not None: + adapter = HTTPAdapter(max_retries=retry) + session.mount("https://", adapter) + session.mount("http://", adapter) + + if proxy: + session.proxies = {"http": proxy, "https": proxy} + + session.verify = ssl_verify + + if user_agent: + session.headers["User-Agent"] = user_agent + + if headers: + session.headers.update(headers) + + if api_key: + session.headers["Authorization"] = f"Bearer {api_key}" + + return session diff --git a/cloudsmith_cli/core/tests/test_cli_flag_provider.py b/cloudsmith_cli/core/tests/test_cli_flag_provider.py new file mode 100644 index 00000000..1f10f25e --- /dev/null +++ b/cloudsmith_cli/core/tests/test_cli_flag_provider.py @@ -0,0 +1,34 @@ +"""Tests for the CLI flag credential provider.""" + +from cloudsmith_cli.core.credentials import CredentialContext +from cloudsmith_cli.core.credentials.providers import CLIFlagProvider + + +class TestCLIFlagProvider: + def test_resolves_from_context(self): + provider = CLIFlagProvider() + context = CredentialContext(api_key="my-api-key-1234") + result = provider.resolve(context) + assert result is not None + assert result.api_key == "my-api-key-1234" + assert result.source_name == "cli_flag" + assert result.auth_type == "api_key" + assert "1234" in result.source_detail + + def test_returns_none_when_not_set(self): + provider = CLIFlagProvider() + context = CredentialContext(api_key=None) + result = provider.resolve(context) + assert result is None + + def test_returns_none_for_empty_value(self): + provider = CLIFlagProvider() + context = CredentialContext(api_key=" ") + result = provider.resolve(context) + assert result is None + + def test_strips_whitespace(self): + provider = CLIFlagProvider() + context = CredentialContext(api_key=" my-key ") + result = provider.resolve(context) + assert result.api_key == "my-key" diff --git a/cloudsmith_cli/core/tests/test_credential_context.py b/cloudsmith_cli/core/tests/test_credential_context.py new file mode 100644 index 00000000..15c4b846 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_credential_context.py @@ -0,0 +1,14 @@ +"""Tests for the CredentialContext class.""" + +from cloudsmith_cli.core.credentials import CredentialContext + + +class TestCredentialContext: + def test_keyring_refresh_failed_defaults_false(self): + context = CredentialContext() + assert context.keyring_refresh_failed is False + + def test_keyring_refresh_failed_can_be_set(self): + context = CredentialContext() + context.keyring_refresh_failed = True + assert context.keyring_refresh_failed is True diff --git a/cloudsmith_cli/core/tests/test_credential_provider_chain.py b/cloudsmith_cli/core/tests/test_credential_provider_chain.py new file mode 100644 index 00000000..94eb1e46 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_credential_provider_chain.py @@ -0,0 +1,80 @@ +"""Tests for the credential provider chain.""" + +from cloudsmith_cli.core.credentials import ( + CredentialContext, + CredentialProvider, + CredentialProviderChain, + CredentialResult, +) + + +class DummyProvider(CredentialProvider): + """Test provider that returns a configurable result.""" + + def __init__(self, name, result=None, should_raise=False): + self.name = name + self._result = result + self._should_raise = should_raise + + def resolve(self, context): + if self._should_raise: + raise RuntimeError("Provider error") + return self._result + + +class TestCredentialProviderChain: + def test_first_provider_wins(self): + result1 = CredentialResult(api_key="key1", source_name="first") + result2 = CredentialResult(api_key="key2", source_name="second") + chain = CredentialProviderChain( + [ + DummyProvider("p1", result=result1), + DummyProvider("p2", result=result2), + ] + ) + result = chain.resolve(CredentialContext()) + assert result.api_key == "key1" + assert result.source_name == "first" + + def test_falls_through_to_second(self): + result2 = CredentialResult(api_key="key2", source_name="second") + chain = CredentialProviderChain( + [ + DummyProvider("p1", result=None), + DummyProvider("p2", result=result2), + ] + ) + result = chain.resolve(CredentialContext()) + assert result.api_key == "key2" + + def test_returns_none_when_all_fail(self): + chain = CredentialProviderChain( + [ + DummyProvider("p1", result=None), + DummyProvider("p2", result=None), + ] + ) + result = chain.resolve(CredentialContext()) + assert result is None + + def test_skips_erroring_provider(self): + result2 = CredentialResult(api_key="key2", source_name="second") + chain = CredentialProviderChain( + [ + DummyProvider("p1", should_raise=True), + DummyProvider("p2", result=result2), + ] + ) + result = chain.resolve(CredentialContext()) + assert result.api_key == "key2" + + def test_empty_chain(self): + chain = CredentialProviderChain([]) + result = chain.resolve(CredentialContext()) + assert result is None + + def test_default_chain_order(self): + chain = CredentialProviderChain() + assert len(chain.providers) == 2 + assert chain.providers[0].name == "keyring" + assert chain.providers[1].name == "cli_flag" diff --git a/cloudsmith_cli/core/tests/test_init.py b/cloudsmith_cli/core/tests/test_init.py index 5906796c..33881b16 100644 --- a/cloudsmith_cli/core/tests/test_init.py +++ b/cloudsmith_cli/core/tests/test_init.py @@ -1,62 +1,8 @@ -import os -from unittest.mock import patch - -import pytest from cloudsmith_api import Configuration -from ...cli import saml -from .. import keyring from ..api.init import initialise_api -@pytest.fixture -def mocked_get_access_token(): - with patch.object( - keyring, "get_access_token", return_value="dummy_access_token" - ) as get_access_token_mock: - yield get_access_token_mock - - -@pytest.fixture -def mocked_get_refresh_token(): - with patch.object( - keyring, "get_refresh_token", return_value="dummy_refresh_token" - ) as get_refresh_token_mock: - yield get_refresh_token_mock - - -@pytest.fixture -def mocked_should_refresh_access_token(): - with patch.object( - keyring, "should_refresh_access_token", return_value=False - ) as should_refresh_access_token_mock: - yield should_refresh_access_token_mock - - -@pytest.fixture -def mocked_refresh_access_token(): - with patch.object( - saml, - "refresh_access_token", - return_value=("new_access_token", "new_refresh_token"), - ) as refresh_access_token_mock: - yield refresh_access_token_mock - - -@pytest.fixture -def mocked_store_sso_tokens(): - with patch.object(keyring, "store_sso_tokens") as store_sso_tokens_mock: - yield store_sso_tokens_mock - - -@pytest.fixture -def mocked_update_refresh_attempted_at(): - with patch.object( - keyring, "update_refresh_attempted_at" - ) as update_refresh_attempted_at_mock: - yield update_refresh_attempted_at_mock - - class TestInitialiseApi: def setup_class(cls): # pylint: disable=no-self-argument # For the purposes of these tests, we need to explicitly call set_default(None) at the @@ -65,14 +11,10 @@ def setup_class(cls): # pylint: disable=no-self-argument # Configuration class to its vanilla, unmodified behaviour/state. Configuration.set_default(None) - def test_initialise_api_sets_cloudsmith_api_config_default( - self, mocked_get_access_token - ): + def test_initialise_api_sets_cloudsmith_api_config_default(self): """Assert that the extra attributes we add to the cloudsmith_cli.Configuration class are present on newly-created instances of that class. """ - mocked_get_access_token.return_value = None - # Read and understand the Configuration class's initialiser. # Notice how the _default class attribute is used if not None. # https://github.com/cloudsmith-io/cloudsmith-api/blob/57963fff5b7818783b3d87246495275545d505df/bindings/python/src/cloudsmith_api/configuration.py#L32-L40 @@ -122,64 +64,49 @@ def test_initialise_api_sets_cloudsmith_api_config_default( is not new_config_after_initialise ) - def test_initialise_api_with_refreshable_access_token_set( - self, - mocked_get_access_token, - mocked_get_refresh_token, - mocked_should_refresh_access_token, - mocked_refresh_access_token, - mocked_store_sso_tokens, - mocked_update_refresh_attempted_at, - ): - mocked_should_refresh_access_token.return_value = True - - # Ensure keyring is enabled for this test - env = os.environ.copy() - env.pop("CLOUDSMITH_NO_KEYRING", None) - with patch.dict(os.environ, env, clear=True): - config = initialise_api(host="https://example.com") + def test_initialise_api_sets_bearer_auth_with_access_token(self): + """Verify access_token is set as Bearer auth header.""" + from cloudsmith_cli.core.credentials import CredentialResult - assert config.headers == {"Authorization": "Bearer new_access_token"} - mocked_refresh_access_token.assert_called_once() - mocked_store_sso_tokens.assert_called_once_with( - "https://example.com", "new_access_token", "new_refresh_token" + credential = CredentialResult( + api_key="test_access_token", source_name="test", auth_type="bearer" ) + config = initialise_api( + host="https://example.com", + credential=credential, + ) + assert config.headers == {"Authorization": "Bearer test_access_token"} - def test_initialise_api_with_recently_refreshed_access_token_and_empty_basic_auth_set( - self, - mocked_get_access_token, - mocked_get_refresh_token, - mocked_should_refresh_access_token, - mocked_refresh_access_token, - mocked_store_sso_tokens, - mocked_update_refresh_attempted_at, - ): - auth_header = Configuration().get_basic_auth_token() + def test_initialise_api_sets_api_key(self): + """Verify key is set as X-Api-Key header.""" + from cloudsmith_cli.core.credentials import CredentialResult - # Ensure keyring is enabled for this test - env = os.environ.copy() - env.pop("CLOUDSMITH_NO_KEYRING", None) - with patch.dict(os.environ, env, clear=True): - config = initialise_api( - host="https://example.com", headers={"Authorization": auth_header} - ) + credential = CredentialResult( + api_key="test_api_key", source_name="test", auth_type="api_key" + ) + config = initialise_api( + host="https://example.com", + credential=credential, + ) + assert config.api_key["X-Api-Key"] == "test_api_key" - assert config.headers == {"Authorization": "Bearer dummy_access_token"} - assert config.username == "" - assert config.password == "" - mocked_refresh_access_token.assert_not_called() - mocked_store_sso_tokens.assert_not_called() - mocked_update_refresh_attempted_at.assert_not_called() + def test_initialise_api_bearer_credential(self): + """Verify bearer credential sets Authorization header, not X-Api-Key.""" + from cloudsmith_cli.core.credentials import CredentialResult - def test_initialise_api_with_recently_refreshed_access_token_and_present_basic_auth( - self, - mocked_get_access_token, - mocked_get_refresh_token, - mocked_should_refresh_access_token, - mocked_refresh_access_token, - mocked_store_sso_tokens, - mocked_update_refresh_attempted_at, - ): + Configuration.set_default(None) + credential = CredentialResult( + api_key="test_access_token", source_name="test", auth_type="bearer" + ) + config = initialise_api( + host="https://example.com", + credential=credential, + ) + assert config.headers == {"Authorization": "Bearer test_access_token"} + assert "X-Api-Key" not in config.api_key + + def test_initialise_api_with_basic_auth_header(self): + """Verify basic auth header is parsed into username and password.""" temp_config = Configuration() temp_config.username = "username" temp_config.password = "password" @@ -191,181 +118,3 @@ def test_initialise_api_with_recently_refreshed_access_token_and_present_basic_a assert config.headers == {"Authorization": auth_header} assert config.username == "username" assert config.password == "password" - mocked_refresh_access_token.assert_not_called() - mocked_store_sso_tokens.assert_not_called() - mocked_update_refresh_attempted_at.assert_not_called() - - def test_initialise_api_skips_keyring_when_env_var_set( - self, - mocked_get_access_token, - ): - """Verify keyring returns None when CLOUDSMITH_NO_KEYRING=1.""" - mocked_get_access_token.return_value = None - with patch.dict(os.environ, {"CLOUDSMITH_NO_KEYRING": "1"}): - config = initialise_api(host="https://example.com", key="test_api_key") - - # get_access_token is called but returns None due to internal guard - mocked_get_access_token.assert_called_once() - # API key should be used instead - assert config.api_key["X-Api-Key"] == "test_api_key" - - def test_initialise_api_uses_keyring_when_env_var_not_set( - self, - mocked_get_access_token, - mocked_get_refresh_token, - mocked_should_refresh_access_token, - ): - """Verify keyring is accessed when CLOUDSMITH_NO_KEYRING is not set.""" - env = os.environ.copy() - env.pop("CLOUDSMITH_NO_KEYRING", None) - with patch.dict(os.environ, env, clear=True): - config = initialise_api(host="https://example.com") - - # Keyring should be accessed - mocked_get_access_token.assert_called_once() - assert config.headers == {"Authorization": "Bearer dummy_access_token"} - - def test_initialise_api_falls_back_to_api_key_when_sso_refresh_fails( - self, - mocked_get_access_token, - mocked_get_refresh_token, - mocked_should_refresh_access_token, - mocked_refresh_access_token, - mocked_store_sso_tokens, - mocked_update_refresh_attempted_at, - ): - """Verify API key is used as fallback when SSO token refresh fails.""" - from ..api.exceptions import ApiException - - # Simulate SSO token refresh failure - mocked_should_refresh_access_token.return_value = True - mocked_refresh_access_token.side_effect = ApiException( - status=401, detail="Unauthorized" - ) - - # Ensure keyring is enabled for this test - env = os.environ.copy() - env.pop("CLOUDSMITH_NO_KEYRING", None) - with patch.dict(os.environ, env, clear=True): - config = initialise_api(host="https://example.com", key="fallback_api_key") - - # Should not use expired SSO token - assert ( - "Authorization" not in config.headers - or config.headers.get("Authorization") != "Bearer dummy_access_token" - ) - # Should fall back to API key - assert config.api_key["X-Api-Key"] == "fallback_api_key" - mocked_update_refresh_attempted_at.assert_called_once() - mocked_store_sso_tokens.assert_not_called() - - def test_initialise_api_no_auth_when_sso_refresh_fails_without_api_key( - self, - mocked_get_access_token, - mocked_get_refresh_token, - mocked_should_refresh_access_token, - mocked_refresh_access_token, - mocked_store_sso_tokens, - mocked_update_refresh_attempted_at, - ): - """Verify expired SSO token is not used when refresh fails and no API key available.""" - from ..api.exceptions import ApiException - - # Reset Configuration to clear any state from previous tests - Configuration.set_default(None) - - # Simulate SSO token refresh failure - mocked_should_refresh_access_token.return_value = True - mocked_refresh_access_token.side_effect = ApiException( - status=401, detail="Unauthorized" - ) - - # Ensure keyring is enabled for this test - env = os.environ.copy() - env.pop("CLOUDSMITH_NO_KEYRING", None) - with patch.dict(os.environ, env, clear=True): - config = initialise_api(host="https://example.com", key=None) - - # Should not use expired SSO token - assert ( - "Authorization" not in config.headers - or config.headers.get("Authorization") != "Bearer dummy_access_token" - ) - # Should not have API key either - assert "X-Api-Key" not in config.api_key - mocked_update_refresh_attempted_at.assert_called_once() - mocked_store_sso_tokens.assert_not_called() - - def test_initialise_api_uses_direct_access_token_when_keyring_disabled( - self, - mocked_get_access_token, - ): - """Verify a directly provided access_token is used even when keyring is disabled. - - This is the critical path for --request-api-key with CLOUDSMITH_NO_KEYRING=1. - The SSO callback provides the access token directly, bypassing keyring storage. - """ - with patch.dict(os.environ, {"CLOUDSMITH_NO_KEYRING": "1"}): - config = initialise_api( - host="https://example.com", - access_token="sso_direct_token_abc123", - ) - - # Keyring should NOT be accessed - mocked_get_access_token.assert_not_called() - # The directly provided access token should be used as Bearer auth - assert config.headers == {"Authorization": "Bearer sso_direct_token_abc123"} - - def test_initialise_api_direct_access_token_takes_precedence_over_keyring( - self, - mocked_get_access_token, - mocked_should_refresh_access_token, - ): - """Verify a directly provided access_token takes precedence over keyring.""" - env = os.environ.copy() - env.pop("CLOUDSMITH_NO_KEYRING", None) - with patch.dict(os.environ, env, clear=True): - config = initialise_api( - host="https://example.com", - access_token="direct_token_xyz", - ) - - # Keyring should NOT be accessed because we have a direct token - mocked_get_access_token.assert_not_called() - # The direct access token should be used - assert config.headers == {"Authorization": "Bearer direct_token_xyz"} - - def test_initialise_api_direct_access_token_skips_refresh( - self, - mocked_get_access_token, - mocked_get_refresh_token, - mocked_should_refresh_access_token, - mocked_refresh_access_token, - mocked_store_sso_tokens, - mocked_update_refresh_attempted_at, - ): - """Verify a directly provided access_token skips the refresh cycle entirely. - - When the SSO callback provides a fresh token - directly (e.g. for --request-api-key with CLOUDSMITH_NO_KEYRING=1), - we must NOT attempt to refresh it. The refresh path would fail because - there is no refresh_token in keyring, clearing the access_token and - leaving zero authentication. - """ - with patch.dict(os.environ, {"CLOUDSMITH_NO_KEYRING": "1"}): - config = initialise_api( - host="https://example.com", - access_token="fresh_sso_token", - ) - - # Keyring lookup should be skipped (direct token provided) - mocked_get_access_token.assert_not_called() - # should_refresh_access_token is called but returns False - # due to internal should_use_keyring() guard - mocked_should_refresh_access_token.assert_called_once() - # Refresh logic should NOT be triggered - mocked_refresh_access_token.assert_not_called() - mocked_store_sso_tokens.assert_not_called() - mocked_update_refresh_attempted_at.assert_not_called() - # The fresh SSO token should be used as-is - assert config.headers == {"Authorization": "Bearer fresh_sso_token"} diff --git a/cloudsmith_cli/core/tests/test_keyring_provider.py b/cloudsmith_cli/core/tests/test_keyring_provider.py new file mode 100644 index 00000000..3a6732af --- /dev/null +++ b/cloudsmith_cli/core/tests/test_keyring_provider.py @@ -0,0 +1,81 @@ +"""Tests for the keyring credential provider.""" + +import os +from unittest.mock import MagicMock, patch + +from cloudsmith_cli.core.credentials import CredentialContext +from cloudsmith_cli.core.credentials.providers import KeyringProvider + + +class TestKeyringProvider: + def test_returns_none_when_keyring_disabled(self): + provider = KeyringProvider() + with patch.dict(os.environ, {"CLOUDSMITH_NO_KEYRING": "1"}): + result = provider.resolve(CredentialContext()) + assert result is None + + def test_returns_none_when_no_token(self): + from cloudsmith_cli.core import keyring + + provider = KeyringProvider() + env = os.environ.copy() + env.pop("CLOUDSMITH_NO_KEYRING", None) + with patch.dict(os.environ, env, clear=True): + with patch.object(keyring, "should_use_keyring", return_value=True): + with patch.object(keyring, "get_access_token", return_value=None): + result = provider.resolve(CredentialContext()) + assert result is None + + def test_returns_bearer_token(self): + from cloudsmith_cli.core import keyring + + provider = KeyringProvider() + env = os.environ.copy() + env.pop("CLOUDSMITH_NO_KEYRING", None) + with patch.dict(os.environ, env, clear=True): + with patch.object(keyring, "should_use_keyring", return_value=True): + with patch.object( + keyring, "get_access_token", return_value="sso_token" + ): + with patch.object( + keyring, "should_refresh_access_token", return_value=False + ): + result = provider.resolve(CredentialContext()) + assert result is not None + assert result.api_key == "sso_token" + assert result.auth_type == "bearer" + assert result.source_name == "keyring" + + def test_returns_none_on_refresh_failure(self): + from cloudsmith_cli.cli import saml + from cloudsmith_cli.core import keyring + from cloudsmith_cli.core.api.exceptions import ApiException + + provider = KeyringProvider() + context = CredentialContext(session=MagicMock()) + env = os.environ.copy() + env.pop("CLOUDSMITH_NO_KEYRING", None) + with patch.dict(os.environ, env, clear=True): + with patch.object(keyring, "should_use_keyring", return_value=True): + with patch.object( + keyring, "get_access_token", return_value="old_token" + ): + with patch.object( + keyring, "should_refresh_access_token", return_value=True + ): + with patch.object( + keyring, "get_refresh_token", return_value="refresh_tok" + ): + with patch.object( + saml, + "refresh_access_token", + side_effect=ApiException( + status=401, detail="Unauthorized" + ), + ): + with patch.object( + keyring, "update_refresh_attempted_at" + ): + result = provider.resolve(context) + assert result is None + assert context.keyring_refresh_failed is True diff --git a/cloudsmith_cli/core/tests/test_rest.py b/cloudsmith_cli/core/tests/test_rest.py index 364af4e7..08335720 100644 --- a/cloudsmith_cli/core/tests/test_rest.py +++ b/cloudsmith_cli/core/tests/test_rest.py @@ -5,9 +5,21 @@ from ..rest import RestClient +@pytest.fixture(autouse=True) +def patch_httpretty_socket(monkeypatch): + """Patch httpretty's fake socket to handle shutdown() which urllib3 2.0+ calls.""" + import httpretty.core + + monkeypatch.setattr( + httpretty.core.fakesock.socket, + "shutdown", + lambda self, how: None, + raising=False, + ) + + class TestRestClient: @httpretty.activate(allow_net_connect=False, verbose=True) - @pytest.mark.usefixtures("mock_keyring") def test_implicit_retry_for_status_codes(self): """Assert that the rest client retries certain status codes automatically.""" # initialise_api() needs to be called before RestClient can be instantiated, @@ -39,32 +51,3 @@ def test_implicit_retry_for_status_codes(self): assert len(httpretty.latest_requests()) == 6 assert r.status == 200 - - -@pytest.fixture -def mock_keyring(monkeypatch): - """Mock keyring functions to prevent reading real SSO tokens from the system keyring. - - This is necessary because initialise_api() checks the keyring for SSO tokens, - and if found, it attempts to refresh them via a network request. When running - this test in isolation with httpretty mocking enabled, that network request - will fail because it's not mocked. - """ - # Import here to avoid circular imports - import httpretty.core - - from .. import keyring - - # Mock all keyring getter functions to return None/False - monkeypatch.setattr(keyring, "get_access_token", lambda api_host: None) - monkeypatch.setattr(keyring, "get_refresh_token", lambda api_host: None) - monkeypatch.setattr(keyring, "should_refresh_access_token", lambda api_host: False) - - # Patch httpretty's fake socket to handle shutdown() which urllib3 2.0+ calls - # This fixes: "Failed to socket.shutdown because a real socket does not exist" - monkeypatch.setattr( - httpretty.core.fakesock.socket, - "shutdown", - lambda self, how: None, - raising=False, - ) From f30e428164b46d77ca79fee33f66b1c449079985 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Tue, 31 Mar 2026 17:01:10 +0100 Subject: [PATCH 02/21] fix: review feedback --- cloudsmith_cli/cli/decorators.py | 5 +- cloudsmith_cli/cli/tests/conftest.py | 2 +- cloudsmith_cli/cli/webserver.py | 2 +- cloudsmith_cli/core/credentials/__init__.py | 100 ------------------ cloudsmith_cli/core/credentials/chain.py | 61 +++++++++++ cloudsmith_cli/core/credentials/models.py | 33 ++++++ cloudsmith_cli/core/credentials/provider.py | 17 +++ .../core/credentials/providers/cli_flag.py | 3 +- .../credentials/providers/keyring_provider.py | 8 +- cloudsmith_cli/core/credentials/session.py | 63 ----------- cloudsmith_cli/core/rest.py | 28 ++--- .../core/tests/test_cli_flag_provider.py | 2 +- .../core/tests/test_credential_context.py | 2 +- .../tests/test_credential_provider_chain.py | 9 +- cloudsmith_cli/core/tests/test_init.py | 6 +- .../core/tests/test_keyring_provider.py | 2 +- cloudsmith_cli/core/tests/test_rest.py | 16 ++- 17 files changed, 162 insertions(+), 197 deletions(-) create mode 100644 cloudsmith_cli/core/credentials/chain.py create mode 100644 cloudsmith_cli/core/credentials/models.py create mode 100644 cloudsmith_cli/core/credentials/provider.py delete mode 100644 cloudsmith_cli/core/credentials/session.py diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index 06ce7836..bda1fd69 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -7,9 +7,10 @@ from cloudsmith_cli.cli import validators from ..core.api.init import initialise_api as _initialise_api -from ..core.credentials import CredentialContext, CredentialProviderChain -from ..core.credentials.session import create_session as _create_session +from ..core.credentials.chain import CredentialProviderChain +from ..core.credentials.models import CredentialContext from ..core.mcp import server +from ..core.rest import create_requests_session as _create_session from . import config, utils diff --git a/cloudsmith_cli/cli/tests/conftest.py b/cloudsmith_cli/cli/tests/conftest.py index ad6262dd..653e574c 100644 --- a/cloudsmith_cli/cli/tests/conftest.py +++ b/cloudsmith_cli/cli/tests/conftest.py @@ -5,7 +5,7 @@ from ...core.api.init import initialise_api from ...core.api.repos import create_repo, delete_repo -from ...core.credentials import CredentialResult +from ...core.credentials.models import CredentialResult from .utils import random_str diff --git a/cloudsmith_cli/cli/webserver.py b/cloudsmith_cli/cli/webserver.py index 3d2ffca9..060d2ff7 100644 --- a/cloudsmith_cli/cli/webserver.py +++ b/cloudsmith_cli/cli/webserver.py @@ -9,7 +9,7 @@ from ..core.api.exceptions import ApiException from ..core.api.init import initialise_api -from ..core.credentials import CredentialResult +from ..core.credentials.models import CredentialResult from ..core.keyring import store_sso_tokens from .saml import exchange_2fa_token diff --git a/cloudsmith_cli/core/credentials/__init__.py b/cloudsmith_cli/core/credentials/__init__.py index d55413ce..e69de29b 100644 --- a/cloudsmith_cli/core/credentials/__init__.py +++ b/cloudsmith_cli/core/credentials/__init__.py @@ -1,100 +0,0 @@ -"""Credential Provider Chain for Cloudsmith CLI. - -Implements an AWS SDK-style credential resolution chain that evaluates -credential sources sequentially and returns the first valid result. -""" - -from __future__ import annotations - -import logging -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - import requests - -logger = logging.getLogger(__name__) - - -@dataclass -class CredentialContext: - """Context passed to credential providers during resolution. - - All values are populated directly from Click options / ``opts``. - """ - - session: requests.Session | None = None - api_key: str | None = None - api_host: str = "https://api.cloudsmith.io" - creds_file_path: str | None = None - profile: str | None = None - debug: bool = False - keyring_refresh_failed: bool = False - - -@dataclass -class CredentialResult: - """Result from a successful credential resolution.""" - - api_key: str - source_name: str - source_detail: str | None = None - auth_type: str = "api_key" - - -class CredentialProvider(ABC): - """Base class for credential providers.""" - - name: str = "base" - - @abstractmethod - def resolve(self, context: CredentialContext) -> CredentialResult | None: - """Attempt to resolve credentials. Return CredentialResult or None.""" - - -class CredentialProviderChain: - """Evaluates credential providers in order, returning the first valid result. - - If no providers are given, uses the default chain: - Keyring → CLIFlag. - """ - - def __init__(self, providers: list[CredentialProvider] | None = None): - if providers is not None: - self.providers = providers - else: - from .providers import CLIFlagProvider, KeyringProvider - - self.providers = [ - KeyringProvider(), - CLIFlagProvider(), - ] - - def resolve(self, context: CredentialContext) -> CredentialResult | None: - """Evaluate each provider in order. Return the first successful result.""" - for provider in self.providers: - try: - result = provider.resolve(context) - if result is not None: - if context.debug: - logger.debug( - "Credentials resolved by %s: %s", - provider.name, - result.source_detail or result.source_name, - ) - return result - if context.debug: - logger.debug( - "Provider %s did not resolve credentials, trying next", - provider.name, - ) - except Exception: # pylint: disable=broad-exception-caught - # Intentionally broad - one provider failing shouldn't stop others - logger.debug( - "Provider %s raised an exception, skipping", - provider.name, - exc_info=True, - ) - continue - return None diff --git a/cloudsmith_cli/core/credentials/chain.py b/cloudsmith_cli/core/credentials/chain.py new file mode 100644 index 00000000..259490fc --- /dev/null +++ b/cloudsmith_cli/core/credentials/chain.py @@ -0,0 +1,61 @@ +"""Credential provider chain for the Cloudsmith CLI. + +Implements an AWS SDK-style credential resolution chain that evaluates +credential sources sequentially and returns the first valid result. +""" + +from __future__ import annotations + +import logging + +from .models import CredentialContext, CredentialResult +from .provider import CredentialProvider + +logger = logging.getLogger(__name__) + + +class CredentialProviderChain: + """Evaluates credential providers in order, returning the first valid result. + + If no providers are given, uses the default chain: + Keyring → CLIFlag. + """ + + def __init__(self, providers: list[CredentialProvider] | None = None): + if providers is not None: + self.providers = providers + else: + from .providers import CLIFlagProvider, KeyringProvider + + self.providers = [ + KeyringProvider(), + CLIFlagProvider(), + ] + + def resolve(self, context: CredentialContext) -> CredentialResult | None: + """Evaluate each provider in order. Return the first successful result.""" + for provider in self.providers: + try: + result = provider.resolve(context) + if result is not None: + if context.debug: + logger.debug( + "Credentials resolved by %s: %s", + provider.name, + result.source_detail or result.source_name, + ) + return result + if context.debug: + logger.debug( + "Provider %s did not resolve credentials, trying next", + provider.name, + ) + except Exception: # pylint: disable=broad-exception-caught + # Intentionally broad - one provider failing shouldn't stop others + logger.debug( + "Provider %s raised an exception, skipping", + provider.name, + exc_info=True, + ) + continue + return None diff --git a/cloudsmith_cli/core/credentials/models.py b/cloudsmith_cli/core/credentials/models.py new file mode 100644 index 00000000..73b3416e --- /dev/null +++ b/cloudsmith_cli/core/credentials/models.py @@ -0,0 +1,33 @@ +"""Credential data models for the Cloudsmith CLI.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import requests + + +@dataclass +class CredentialContext: + """Context passed to credential providers during resolution. + + All values are populated directly from Click options / ``opts``. + """ + + session: requests.Session | None = None + api_key: str | None = None + api_host: str = "https://api.cloudsmith.io" + creds_file_path: str | None = None + profile: str | None = None + debug: bool = False + keyring_refresh_failed: bool = False + + +@dataclass +class CredentialResult: + """Result from a successful credential resolution.""" + + api_key: str + source_name: str + source_detail: str | None = None + auth_type: str = "api_key" diff --git a/cloudsmith_cli/core/credentials/provider.py b/cloudsmith_cli/core/credentials/provider.py new file mode 100644 index 00000000..78b18886 --- /dev/null +++ b/cloudsmith_cli/core/credentials/provider.py @@ -0,0 +1,17 @@ +"""Base credential provider interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from .models import CredentialContext, CredentialResult + + +class CredentialProvider(ABC): + """Base class for credential providers.""" + + name: str = "base" + + @abstractmethod + def resolve(self, context: CredentialContext) -> CredentialResult | None: + """Attempt to resolve credentials. Return CredentialResult or None.""" diff --git a/cloudsmith_cli/core/credentials/providers/cli_flag.py b/cloudsmith_cli/core/credentials/providers/cli_flag.py index 99b55dcf..0a05d027 100644 --- a/cloudsmith_cli/core/credentials/providers/cli_flag.py +++ b/cloudsmith_cli/core/credentials/providers/cli_flag.py @@ -2,7 +2,8 @@ from __future__ import annotations -from .. import CredentialContext, CredentialProvider, CredentialResult +from ..models import CredentialContext, CredentialResult +from ..provider import CredentialProvider class CLIFlagProvider(CredentialProvider): diff --git a/cloudsmith_cli/core/credentials/providers/keyring_provider.py b/cloudsmith_cli/core/credentials/providers/keyring_provider.py index c4f44e95..c8844f0c 100644 --- a/cloudsmith_cli/core/credentials/providers/keyring_provider.py +++ b/cloudsmith_cli/core/credentials/providers/keyring_provider.py @@ -4,7 +4,10 @@ import logging -from .. import CredentialContext, CredentialProvider, CredentialResult +from ....cli.saml import refresh_access_token +from ....core import keyring +from ..models import CredentialContext, CredentialResult +from ..provider import CredentialProvider logger = logging.getLogger(__name__) @@ -15,9 +18,6 @@ class KeyringProvider(CredentialProvider): name = "keyring" def resolve(self, context: CredentialContext) -> CredentialResult | None: - from ....cli.saml import refresh_access_token - from ....core import keyring - if not keyring.should_use_keyring(): return None diff --git a/cloudsmith_cli/core/credentials/session.py b/cloudsmith_cli/core/credentials/session.py deleted file mode 100644 index 9e314efb..00000000 --- a/cloudsmith_cli/core/credentials/session.py +++ /dev/null @@ -1,63 +0,0 @@ -"""HTTP session factory with networking configuration and retry support.""" - -from __future__ import annotations - -import requests -from requests.adapters import HTTPAdapter -from urllib3.util.retry import Retry - -#: Default retry policy: retry on connection errors, 429, and 5xx responses -#: with exponential back-off (1s, 2s, 4s). -DEFAULT_RETRY = Retry( - total=3, - backoff_factor=1, - status_forcelist=(429, 500, 502, 503, 504), - allowed_methods=("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"), - raise_on_status=False, -) - - -def create_session( - proxy: str | None = None, - ssl_verify: bool = True, - user_agent: str | None = None, - headers: dict | None = None, - api_key: str | None = None, - retry: Retry | None = DEFAULT_RETRY, -) -> requests.Session: - """Create a requests session with networking, auth, and retry configuration. - - Args: - proxy: HTTP/HTTPS proxy URL. - ssl_verify: Whether to verify SSL certificates. - user_agent: Custom User-Agent header value. - headers: Additional headers to include in every request. - api_key: If provided, set as a Bearer token in the Authorization header. - retry: urllib3 Retry configuration. Defaults to :data:`DEFAULT_RETRY`. - Pass ``None`` to disable automatic retries. - - Returns: - A configured :class:`requests.Session`. - """ - session = requests.Session() - - if retry is not None: - adapter = HTTPAdapter(max_retries=retry) - session.mount("https://", adapter) - session.mount("http://", adapter) - - if proxy: - session.proxies = {"http": proxy, "https": proxy} - - session.verify = ssl_verify - - if user_agent: - session.headers["User-Agent"] = user_agent - - if headers: - session.headers.update(headers) - - if api_key: - session.headers["Authorization"] = f"Bearer {api_key}" - - return session diff --git a/cloudsmith_cli/core/rest.py b/cloudsmith_cli/core/rest.py index 3820b50b..e9652931 100644 --- a/cloudsmith_cli/core/rest.py +++ b/cloudsmith_cli/core/rest.py @@ -61,28 +61,26 @@ def create_requests_session( session=None, error_retry_cb=None, respect_retry_after_header=True, + user_agent=None, + headers=None, ): """Create a requests session that retries some errors.""" # pylint: disable=too-many-branches config = Configuration() if retries is None: - if config.error_retry_max is None: # pylint: disable=no-member - retries = 5 - else: - retries = config.error_retry_max # pylint: disable=no-member + retry_max = getattr(config, "error_retry_max", None) + retries = retry_max if retry_max is not None else 5 if backoff_factor is None: - if config.error_retry_backoff is None: # pylint: disable=no-member - backoff_factor = 0.23 - else: - backoff_factor = config.error_retry_backoff # pylint: disable=no-member + retry_backoff = getattr(config, "error_retry_backoff", None) + backoff_factor = retry_backoff if retry_backoff is not None else 0.23 if status_forcelist is None: - if config.error_retry_codes is None: # pylint: disable=no-member - status_forcelist = [500, 502, 503, 504] - else: - status_forcelist = config.error_retry_codes # pylint: disable=no-member + retry_codes = getattr(config, "error_retry_codes", None) + status_forcelist = ( + retry_codes if retry_codes is not None else [500, 502, 503, 504] + ) if ssl_verify is None: ssl_verify = config.verify_ssl @@ -125,6 +123,12 @@ def create_requests_session( session.mount("http://", adapter) session.mount("https://", adapter) + if user_agent: + session.headers["User-Agent"] = user_agent + + if headers: + session.headers.update(headers) + return session diff --git a/cloudsmith_cli/core/tests/test_cli_flag_provider.py b/cloudsmith_cli/core/tests/test_cli_flag_provider.py index 1f10f25e..dc4c0ff7 100644 --- a/cloudsmith_cli/core/tests/test_cli_flag_provider.py +++ b/cloudsmith_cli/core/tests/test_cli_flag_provider.py @@ -1,6 +1,6 @@ """Tests for the CLI flag credential provider.""" -from cloudsmith_cli.core.credentials import CredentialContext +from cloudsmith_cli.core.credentials.models import CredentialContext from cloudsmith_cli.core.credentials.providers import CLIFlagProvider diff --git a/cloudsmith_cli/core/tests/test_credential_context.py b/cloudsmith_cli/core/tests/test_credential_context.py index 15c4b846..49dc57d9 100644 --- a/cloudsmith_cli/core/tests/test_credential_context.py +++ b/cloudsmith_cli/core/tests/test_credential_context.py @@ -1,6 +1,6 @@ """Tests for the CredentialContext class.""" -from cloudsmith_cli.core.credentials import CredentialContext +from cloudsmith_cli.core.credentials.models import CredentialContext class TestCredentialContext: diff --git a/cloudsmith_cli/core/tests/test_credential_provider_chain.py b/cloudsmith_cli/core/tests/test_credential_provider_chain.py index 94eb1e46..58cddb00 100644 --- a/cloudsmith_cli/core/tests/test_credential_provider_chain.py +++ b/cloudsmith_cli/core/tests/test_credential_provider_chain.py @@ -1,11 +1,8 @@ """Tests for the credential provider chain.""" -from cloudsmith_cli.core.credentials import ( - CredentialContext, - CredentialProvider, - CredentialProviderChain, - CredentialResult, -) +from cloudsmith_cli.core.credentials.chain import CredentialProviderChain +from cloudsmith_cli.core.credentials.models import CredentialContext, CredentialResult +from cloudsmith_cli.core.credentials.provider import CredentialProvider class DummyProvider(CredentialProvider): diff --git a/cloudsmith_cli/core/tests/test_init.py b/cloudsmith_cli/core/tests/test_init.py index 33881b16..f0b691e6 100644 --- a/cloudsmith_cli/core/tests/test_init.py +++ b/cloudsmith_cli/core/tests/test_init.py @@ -66,7 +66,7 @@ def test_initialise_api_sets_cloudsmith_api_config_default(self): def test_initialise_api_sets_bearer_auth_with_access_token(self): """Verify access_token is set as Bearer auth header.""" - from cloudsmith_cli.core.credentials import CredentialResult + from cloudsmith_cli.core.credentials.models import CredentialResult credential = CredentialResult( api_key="test_access_token", source_name="test", auth_type="bearer" @@ -79,7 +79,7 @@ def test_initialise_api_sets_bearer_auth_with_access_token(self): def test_initialise_api_sets_api_key(self): """Verify key is set as X-Api-Key header.""" - from cloudsmith_cli.core.credentials import CredentialResult + from cloudsmith_cli.core.credentials.models import CredentialResult credential = CredentialResult( api_key="test_api_key", source_name="test", auth_type="api_key" @@ -92,7 +92,7 @@ def test_initialise_api_sets_api_key(self): def test_initialise_api_bearer_credential(self): """Verify bearer credential sets Authorization header, not X-Api-Key.""" - from cloudsmith_cli.core.credentials import CredentialResult + from cloudsmith_cli.core.credentials.models import CredentialResult Configuration.set_default(None) credential = CredentialResult( diff --git a/cloudsmith_cli/core/tests/test_keyring_provider.py b/cloudsmith_cli/core/tests/test_keyring_provider.py index 3a6732af..dcd1ba52 100644 --- a/cloudsmith_cli/core/tests/test_keyring_provider.py +++ b/cloudsmith_cli/core/tests/test_keyring_provider.py @@ -3,7 +3,7 @@ import os from unittest.mock import MagicMock, patch -from cloudsmith_cli.core.credentials import CredentialContext +from cloudsmith_cli.core.credentials.models import CredentialContext from cloudsmith_cli.core.credentials.providers import KeyringProvider diff --git a/cloudsmith_cli/core/tests/test_rest.py b/cloudsmith_cli/core/tests/test_rest.py index 08335720..d2a6e6d8 100644 --- a/cloudsmith_cli/core/tests/test_rest.py +++ b/cloudsmith_cli/core/tests/test_rest.py @@ -2,7 +2,7 @@ import pytest from ..api.init import initialise_api -from ..rest import RestClient +from ..rest import RestClient, create_requests_session @pytest.fixture(autouse=True) @@ -51,3 +51,17 @@ def test_implicit_retry_for_status_codes(self): assert len(httpretty.latest_requests()) == 6 assert r.status == 200 + + +class TestCreateRequestsSession: + @pytest.fixture(autouse=True) + def setup(self): + initialise_api() + + def test_sets_user_agent_header(self): + session = create_requests_session(user_agent="test-agent/1.0") + assert session.headers["User-Agent"] == "test-agent/1.0" + + def test_sets_extra_headers(self): + session = create_requests_session(headers={"X-Custom": "value"}) + assert session.headers["X-Custom"] == "value" From 05fd479a7f3e8e1969b4f23dbb8ad7caecf3e682 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Tue, 31 Mar 2026 22:35:07 +0100 Subject: [PATCH 03/21] feat: add Docker credential helper for Cloudsmith registries --- cloudsmith_cli/cli/commands/__init__.py | 1 + .../commands/credential_helper/__init__.py | 31 +++ .../cli/commands/credential_helper/docker.py | 80 +++++++ cloudsmith_cli/credential_helpers/__init__.py | 6 + cloudsmith_cli/credential_helpers/common.py | 86 ++++++++ .../credential_helpers/custom_domains.py | 199 ++++++++++++++++++ .../credential_helpers/docker/__init__.py | 3 + .../credential_helpers/docker/credentials.py | 38 ++++ .../credential_helpers/docker/wrapper.py | 76 +++++++ setup.py | 5 +- 10 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 cloudsmith_cli/cli/commands/credential_helper/__init__.py create mode 100644 cloudsmith_cli/cli/commands/credential_helper/docker.py create mode 100644 cloudsmith_cli/credential_helpers/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/common.py create mode 100644 cloudsmith_cli/credential_helpers/custom_domains.py create mode 100644 cloudsmith_cli/credential_helpers/docker/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/docker/credentials.py create mode 100644 cloudsmith_cli/credential_helpers/docker/wrapper.py diff --git a/cloudsmith_cli/cli/commands/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index af10c90e..d7d588ee 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -4,6 +4,7 @@ auth, check, copy, + credential_helper, delete, dependencies, docs, diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py new file mode 100644 index 00000000..10727bf4 --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -0,0 +1,31 @@ +""" +Credential helper commands for Cloudsmith. + +This module provides credential helper commands for package managers +that follow their respective credential helper protocols. +""" + +import click + +from ..main import main +from .docker import docker as docker_cmd + + +@click.group() +def credential_helper(): + """ + Credential helpers for package managers. + + These commands provide credentials for package managers like Docker. + They are typically called by wrapper binaries + (e.g., docker-credential-cloudsmith) or used directly for debugging. + + Examples: + # Test Docker credential helper + $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker + """ + + +credential_helper.add_command(docker_cmd, name="docker") + +main.add_command(credential_helper, name="credential-helper") diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py new file mode 100644 index 00000000..8d0f99fd --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -0,0 +1,80 @@ +""" +Docker credential helper command. + +Implements the Docker credential helper protocol for Cloudsmith registries. + +See: https://github.com/docker/docker-credential-helpers +""" + +import json +import sys + +import click + +from ....credential_helpers.docker import get_credentials +from ...decorators import common_api_auth_options, resolve_credentials + + +@click.command() +@common_api_auth_options +@resolve_credentials +def docker(opts): + """ + Docker credential helper for Cloudsmith registries. + + Reads a Docker registry server URL from stdin and returns credentials in JSON format. + This command implements the 'get' operation of the Docker credential helper protocol. + + Only provides credentials for Cloudsmith Docker registries (docker.cloudsmith.io). + + Input (stdin): + Server URL as plain text (e.g., "docker.cloudsmith.io") + + Output (stdout): + JSON: {"Username": "token", "Secret": ""} + + Exit codes: + 0: Success + 1: Error (no credentials available, not a Cloudsmith registry, etc.) + + Examples: + # Manual testing + $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker + {"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."} + + # Called by Docker via wrapper + $ echo "docker.cloudsmith.io" | docker-credential-cloudsmith get + {"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."} + + Environment variables: + CLOUDSMITH_API_KEY: API key for authentication (optional) + CLOUDSMITH_ORG: Organization slug (required for custom domain support) + """ + try: + server_url = sys.stdin.read().strip() + + if not server_url: + click.echo("Error: No server URL provided on stdin", err=True) + sys.exit(1) + + credentials = get_credentials( + server_url, + credential=opts.credential, + session=opts.session, + api_host=opts.api_host or "https://api.cloudsmith.io", + ) + + if not credentials: + click.echo( + "Error: Unable to retrieve credentials. " + "Make sure you have a valid cloudsmith-cli session, " + "this can be checked with `cloudsmith whoami`.", + err=True, + ) + sys.exit(1) + + click.echo(json.dumps(credentials)) + + except OSError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) diff --git a/cloudsmith_cli/credential_helpers/__init__.py b/cloudsmith_cli/credential_helpers/__init__.py new file mode 100644 index 00000000..6e6715ce --- /dev/null +++ b/cloudsmith_cli/credential_helpers/__init__.py @@ -0,0 +1,6 @@ +""" +Credential helpers for various package managers. + +This package provides credential helper implementations for Docker, pip, npm, etc. +Each helper follows its respective package manager's credential helper protocol. +""" diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py new file mode 100644 index 00000000..300d468b --- /dev/null +++ b/cloudsmith_cli/credential_helpers/common.py @@ -0,0 +1,86 @@ +""" +Shared utilities for credential helpers. + +Provides domain checking used by all credential helpers. +""" + +import logging +import os + +logger = logging.getLogger(__name__) + + +def extract_hostname(url): + """ + Extract bare hostname from any URL format. + + Handles protocols, sparse+ prefix, ports, paths, and trailing slashes. + + Args: + url: URL in any format (e.g., "sparse+https://cargo.cloudsmith.io/org/repo/") + + Returns: + str: Lowercase hostname (e.g., "cargo.cloudsmith.io") + """ + if not url: + return "" + + normalized = url.lower().strip() + + # Remove sparse+ prefix (Cargo) + if normalized.startswith("sparse+"): + normalized = normalized[7:] + + # Remove protocol + if "://" in normalized: + normalized = normalized.split("://", 1)[1] + + # Remove userinfo (user@host) + if "@" in normalized.split("/")[0]: + normalized = normalized.split("@", 1)[1] + + # Extract hostname (before first / or :) + hostname = normalized.split("/")[0].split(":")[0] + + return hostname + + +def is_cloudsmith_domain(url, session=None, api_key=None, api_host=None): + """ + Check if a URL points to a Cloudsmith service. + + Checks standard *.cloudsmith.io domains first (no auth needed). + If not a standard domain, queries the Cloudsmith API for custom domains. + + Args: + url: URL or hostname to check + session: Pre-configured requests.Session with proxy/SSL settings + api_key: API key for authenticating custom domain lookups + api_host: Cloudsmith API host URL + + Returns: + bool: True if this is a Cloudsmith domain + """ + hostname = extract_hostname(url) + if not hostname: + return False + + # Standard Cloudsmith domains — no auth needed + if hostname.endswith("cloudsmith.io") or hostname == "cloudsmith.io": + return True + + # Custom domains require org + auth + org = os.environ.get("CLOUDSMITH_ORG", "").strip() + if not org: + return False + + if not api_key: + return False + + from .custom_domains import get_custom_domains_for_org + + custom_domains = get_custom_domains_for_org( + org, session=session, api_key=api_key, api_host=api_host + ) + + return hostname in [d.lower() for d in custom_domains] diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py new file mode 100644 index 00000000..790ec44d --- /dev/null +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -0,0 +1,199 @@ +""" +Helper for discovering Cloudsmith custom domains. + +This module provides functions to fetch custom domains from the Cloudsmith API +for use in credential helpers. Results are cached on the filesystem. +""" + +import json +import logging +import time +from pathlib import Path +from typing import List, Optional + +import requests + +logger = logging.getLogger(__name__) + +# Cache custom domains for 1 hour +CACHE_TTL_SECONDS = 3600 + + +def get_cache_dir() -> Path: + """ + Get the cache directory for custom domains. + + Returns: + Path to cache directory (e.g., ~/.cloudsmith/cache/custom_domains/) + """ + home = Path.home() + cache_dir = home / ".cloudsmith" / "cache" / "custom_domains" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +def get_cache_path(org: str) -> Path: + """ + Get the cache file path for an organization's custom domains. + + Args: + org: Organization slug + + Returns: + Path to cache file + """ + cache_dir = get_cache_dir() + safe_org = "".join(c if c.isalnum() or c in "-_" else "_" for c in org) + return cache_dir / f"{safe_org}.json" + + +def is_cache_valid(cache_path: Path) -> bool: + """ + Check if a cache file exists and is still valid. + + Args: + cache_path: Path to cache file + + Returns: + bool: True if cache exists and hasn't expired + """ + if not cache_path.exists(): + return False + + try: + mtime = cache_path.stat().st_mtime + age = time.time() - mtime + return age < CACHE_TTL_SECONDS + except OSError: + return False + + +def read_cache(cache_path: Path) -> Optional[List[str]]: + """ + Read custom domains from cache file. + + Args: + cache_path: Path to cache file + + Returns: + List of domain strings or None if cache invalid/missing + """ + if not is_cache_valid(cache_path): + return None + + try: + with open(cache_path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict) and "domains" in data: + domains = data["domains"] + if isinstance(domains, list): + logger.debug( + "Read %d domains from cache: %s", len(domains), cache_path + ) + return domains + except (OSError, json.JSONDecodeError) as exc: + logger.debug("Failed to read cache %s: %s", cache_path, exc) + + return None + + +def write_cache(cache_path: Path, domains: List[str]) -> None: + """ + Write custom domains to cache file. + + Args: + cache_path: Path to cache file + domains: List of domain strings to cache + """ + try: + data = { + "domains": domains, + "cached_at": time.time(), + } + with open(cache_path, "w", encoding="utf-8") as f: + json.dump(data, f) + logger.debug("Wrote %d domains to cache: %s", len(domains), cache_path) + except OSError as exc: + logger.debug("Failed to write cache %s: %s", cache_path, exc) + + +def get_custom_domains_for_org( # pylint: disable=too-many-return-statements + org: str, + session=None, + api_key: str = None, + api_host: str = None, +) -> List[str]: + """ + Fetch custom domains for a Cloudsmith organization. + + Results are cached on the filesystem for 1 hour to avoid excessive API calls. + + Args: + org: Organization slug + session: Pre-configured requests.Session with proxy/SSL settings. + If None, a plain requests session is used. + api_key: Optional API key for authentication + api_host: Cloudsmith API host URL. Defaults to https://api.cloudsmith.io. + + Returns: + List of custom domain strings (e.g., ['docker.customer.com', 'dl.customer.com']) + Empty list if API call fails or org has no custom domains + """ + cache_path = get_cache_path(org) + cached_domains = read_cache(cache_path) + if cached_domains is not None: + logger.debug("Using cached custom domains for %s", org) + return cached_domains + + logger.debug("Fetching custom domains from API for %s", org) + + try: + if session is None: + session = requests.Session() + + if api_key: + session.headers["Authorization"] = f"Bearer {api_key}" + + host = api_host or "https://api.cloudsmith.io" + url = f"{host}/orgs/{org}/custom-domains/" + + response = session.get(url, timeout=10) + + if response.status_code in (401, 403): + logger.debug( + "Custom domains API requires auth - assuming no custom domains for %s", + org, + ) + return [] # Don't cache 401/403 - might work later with auth + + if response.status_code == 404: + logger.debug("Organization %s not found or has no custom domains", org) + write_cache(cache_path, []) # Cache empty result to avoid repeated 404s + return [] + + if response.status_code != 200: + logger.debug( + "Failed to fetch custom domains for %s: HTTP %d", + org, + response.status_code, + ) + return [] + + data = response.json() + + # Expected format: [{"host": "docker.customer.com", ...}, ...] + domains = [] + if isinstance(data, list): + for item in data: + if isinstance(item, dict) and "host" in item: + domains.append(item["host"]) + + logger.debug("Fetched %d custom domains for %s", len(domains), org) + + write_cache(cache_path, domains) + + return domains + + except (requests.RequestException, ValueError) as exc: + logger.debug("Error fetching custom domains: %s", exc) + return [] diff --git a/cloudsmith_cli/credential_helpers/docker/__init__.py b/cloudsmith_cli/credential_helpers/docker/__init__.py new file mode 100644 index 00000000..a96d42d1 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/docker/__init__.py @@ -0,0 +1,3 @@ +from .credentials import get_credentials + +__all__ = ["get_credentials"] diff --git a/cloudsmith_cli/credential_helpers/docker/credentials.py b/cloudsmith_cli/credential_helpers/docker/credentials.py new file mode 100644 index 00000000..a5f4e9bd --- /dev/null +++ b/cloudsmith_cli/credential_helpers/docker/credentials.py @@ -0,0 +1,38 @@ +""" +Docker credential helper logic for Cloudsmith. + +This module provides functions for retrieving credentials for Docker registries +using the existing Cloudsmith credential provider chain (OIDC, API keys, config, keyring). +""" + +from ..common import is_cloudsmith_domain + + +def get_credentials(server_url, credential=None, session=None, api_host=None): + """ + Get credentials for a Cloudsmith Docker registry. + + Verifies the URL is a Cloudsmith registry (including custom domains) + and returns credentials if available. + + Args: + server_url: The Docker registry server URL + credential: Pre-resolved CredentialResult from the provider chain + session: Pre-configured requests.Session with proxy/SSL settings + api_host: Cloudsmith API host URL + + Returns: + dict: Credentials with 'Username' and 'Secret' keys, or None + """ + if not credential or not credential.api_key: + return None + + if not is_cloudsmith_domain( + server_url, + session=session, + api_key=credential.api_key, + api_host=api_host, + ): + return None + + return {"Username": "token", "Secret": credential.api_key} diff --git a/cloudsmith_cli/credential_helpers/docker/wrapper.py b/cloudsmith_cli/credential_helpers/docker/wrapper.py new file mode 100644 index 00000000..85f050ee --- /dev/null +++ b/cloudsmith_cli/credential_helpers/docker/wrapper.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +""" +Wrapper for docker-credential-cloudsmith. + +This is the entry point binary that Docker calls. It delegates to the main +cloudsmith credential-helper docker command. + +See: https://github.com/docker/docker-credential-helpers + +Configure in ~/.docker/config.json: + { + "credHelpers": { + "docker.cloudsmith.io": "cloudsmith" + } + } +""" +import subprocess +import sys + + +def main(): + """ + Docker credential helper wrapper. + + Docker calls this with the operation as argv[1]: + - get: Retrieve credentials + - store: Store credentials (not supported) + - erase: Erase credentials (not supported) + - list: List credentials (not supported) + + We only support 'get' and delegate to: cloudsmith credential-helper docker + """ + if len(sys.argv) < 2: + print( + "Error: Missing operation argument. " + "Usage: docker-credential-cloudsmith ", + file=sys.stderr, + ) + sys.exit(1) + + operation = sys.argv[1] + + if operation == "get": + try: + result = subprocess.run( + ["cloudsmith", "credential-helper", "docker"], + stdin=sys.stdin, + capture_output=False, + check=False, + ) + sys.exit(result.returncode) + except FileNotFoundError: + print( + "Error: 'cloudsmith' command not found. " + "Make sure cloudsmith-cli is installed.", + file=sys.stderr, + ) + sys.exit(1) + elif operation in ("store", "erase", "list"): + print( + f"Error: Operation '{operation}' is not supported. " + "Only 'get' is available for Cloudsmith credential helper.", + file=sys.stderr, + ) + sys.exit(1) + else: + print( + f"Error: Unknown operation '{operation}'. " + "Valid operations: get, store, erase, list", + file=sys.stderr, + ) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/setup.py b/setup.py index cf013b1f..d05aa94d 100644 --- a/setup.py +++ b/setup.py @@ -65,7 +65,10 @@ def get_long_description(): "urllib3>=2.5", ], entry_points={ - "console_scripts": ["cloudsmith=cloudsmith_cli.cli.commands.main:main"] + "console_scripts": [ + "cloudsmith=cloudsmith_cli.cli.commands.main:main", + "docker-credential-cloudsmith=cloudsmith_cli.credential_helpers.docker.wrapper:main", + ] }, keywords=["cloudsmith", "cli", "devops"], classifiers=[ From 8f5e800ace478da135064dda55e5228d0fd5fe11 Mon Sep 17 00:00:00 2001 From: BB <55028730+BartoszBlizniak@users.noreply.github.com> Date: Wed, 27 May 2026 17:33:22 +0100 Subject: [PATCH 04/21] Potential fix for pull request finding 'CodeQL / Incomplete URL substring sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- cloudsmith_cli/credential_helpers/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index 300d468b..17c843a7 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -66,7 +66,7 @@ def is_cloudsmith_domain(url, session=None, api_key=None, api_host=None): return False # Standard Cloudsmith domains — no auth needed - if hostname.endswith("cloudsmith.io") or hostname == "cloudsmith.io": + if hostname == "cloudsmith.io" or hostname.endswith(".cloudsmith.io"): return True # Custom domains require org + auth From 78e06ff0382840c761118546d3230f4e98e8fbab Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Thu, 28 May 2026 10:34:01 +0100 Subject: [PATCH 05/21] fix: copilot feedback --- .../cli/commands/credential_helper/docker.py | 4 ++- cloudsmith_cli/credential_helpers/common.py | 13 +++++++-- .../credential_helpers/custom_domains.py | 29 ++++++++++++------- .../credential_helpers/docker/credentials.py | 1 + 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py index 8d0f99fd..c8a55d59 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/docker.py +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -25,7 +25,9 @@ def docker(opts): Reads a Docker registry server URL from stdin and returns credentials in JSON format. This command implements the 'get' operation of the Docker credential helper protocol. - Only provides credentials for Cloudsmith Docker registries (docker.cloudsmith.io). + Only provides credentials for Cloudsmith Docker registries: `*.cloudsmith.io` + and any custom domains configured for the organization (requires CLOUDSMITH_ORG + and a valid API key/token). Input (stdin): Server URL as plain text (e.g., "docker.cloudsmith.io") diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index 17c843a7..8c7eb594 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -45,7 +45,9 @@ def extract_hostname(url): return hostname -def is_cloudsmith_domain(url, session=None, api_key=None, api_host=None): +def is_cloudsmith_domain( + url, session=None, api_key=None, auth_type="api_key", api_host=None +): """ Check if a URL points to a Cloudsmith service. @@ -55,7 +57,8 @@ def is_cloudsmith_domain(url, session=None, api_key=None, api_host=None): Args: url: URL or hostname to check session: Pre-configured requests.Session with proxy/SSL settings - api_key: API key for authenticating custom domain lookups + api_key: API key/token for authenticating custom domain lookups + auth_type: "api_key" (X-Api-Key header) or "bearer" (Authorization: Bearer) api_host: Cloudsmith API host URL Returns: @@ -80,7 +83,11 @@ def is_cloudsmith_domain(url, session=None, api_key=None, api_host=None): from .custom_domains import get_custom_domains_for_org custom_domains = get_custom_domains_for_org( - org, session=session, api_key=api_key, api_host=api_host + org, + session=session, + api_key=api_key, + auth_type=auth_type, + api_host=api_host, ) return hostname in [d.lower() for d in custom_domains] diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py index 790ec44d..079dc9e3 100644 --- a/cloudsmith_cli/credential_helpers/custom_domains.py +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -9,7 +9,6 @@ import logging import time from pathlib import Path -from typing import List, Optional import requests @@ -68,7 +67,7 @@ def is_cache_valid(cache_path: Path) -> bool: return False -def read_cache(cache_path: Path) -> Optional[List[str]]: +def read_cache(cache_path: Path) -> list[str] | None: """ Read custom domains from cache file. @@ -97,7 +96,7 @@ def read_cache(cache_path: Path) -> Optional[List[str]]: return None -def write_cache(cache_path: Path, domains: List[str]) -> None: +def write_cache(cache_path: Path, domains: list[str]) -> None: """ Write custom domains to cache file. @@ -120,9 +119,10 @@ def write_cache(cache_path: Path, domains: List[str]) -> None: def get_custom_domains_for_org( # pylint: disable=too-many-return-statements org: str, session=None, - api_key: str = None, - api_host: str = None, -) -> List[str]: + api_key: str | None = None, + auth_type: str = "api_key", + api_host: str | None = None, +) -> list[str]: """ Fetch custom domains for a Cloudsmith organization. @@ -132,7 +132,8 @@ def get_custom_domains_for_org( # pylint: disable=too-many-return-statements org: Organization slug session: Pre-configured requests.Session with proxy/SSL settings. If None, a plain requests session is used. - api_key: Optional API key for authentication + api_key: Optional API key/token for authentication + auth_type: "api_key" (uses X-Api-Key header) or "bearer" (uses Authorization: Bearer) api_host: Cloudsmith API host URL. Defaults to https://api.cloudsmith.io. Returns: @@ -151,13 +152,17 @@ def get_custom_domains_for_org( # pylint: disable=too-many-return-statements if session is None: session = requests.Session() + headers = {} if api_key: - session.headers["Authorization"] = f"Bearer {api_key}" + if auth_type == "bearer": + headers["Authorization"] = f"Bearer {api_key}" + else: + headers["X-Api-Key"] = api_key host = api_host or "https://api.cloudsmith.io" url = f"{host}/orgs/{org}/custom-domains/" - response = session.get(url, timeout=10) + response = session.get(url, headers=headers, timeout=10) if response.status_code in (401, 403): logger.debug( @@ -185,8 +190,10 @@ def get_custom_domains_for_org( # pylint: disable=too-many-return-statements domains = [] if isinstance(data, list): for item in data: - if isinstance(item, dict) and "host" in item: - domains.append(item["host"]) + if isinstance(item, dict): + host = item.get("host") + if isinstance(host, str) and host: + domains.append(host) logger.debug("Fetched %d custom domains for %s", len(domains), org) diff --git a/cloudsmith_cli/credential_helpers/docker/credentials.py b/cloudsmith_cli/credential_helpers/docker/credentials.py index a5f4e9bd..0180a6ed 100644 --- a/cloudsmith_cli/credential_helpers/docker/credentials.py +++ b/cloudsmith_cli/credential_helpers/docker/credentials.py @@ -31,6 +31,7 @@ def get_credentials(server_url, credential=None, session=None, api_host=None): server_url, session=session, api_key=credential.api_key, + auth_type=getattr(credential, "auth_type", "api_key"), api_host=api_host, ): return None From f74c30488ebd8e476fb58c3bf154b130e9fb0768 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Thu, 28 May 2026 11:59:31 +0100 Subject: [PATCH 06/21] fix: cache file handling --- .../cli/commands/credential_helper/docker.py | 5 +- .../tests/commands/test_credential_helper.py | 131 ++++++++++++++++++ cloudsmith_cli/core/cache_utils.py | 35 +++++ cloudsmith_cli/core/credentials/oidc/cache.py | 6 +- cloudsmith_cli/credential_helpers/common.py | 2 +- .../credential_helpers/custom_domains.py | 30 ++-- .../credential_helpers/docker/wrapper.py | 22 +-- 7 files changed, 197 insertions(+), 34 deletions(-) create mode 100644 cloudsmith_cli/cli/tests/commands/test_credential_helper.py create mode 100644 cloudsmith_cli/core/cache_utils.py diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py index c8a55d59..3026e34b 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/docker.py +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -69,8 +69,9 @@ def docker(opts): if not credentials: click.echo( "Error: Unable to retrieve credentials. " - "Make sure you have a valid cloudsmith-cli session, " - "this can be checked with `cloudsmith whoami`.", + "Provide credentials via the CLOUDSMITH_API_KEY environment variable, " + "credentials.ini, the system keyring, or an OIDC service. " + "Verify current authentication with `cloudsmith whoami --verbose`.", err=True, ) sys.exit(1) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py new file mode 100644 index 00000000..9c91465e --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -0,0 +1,131 @@ +"""Tests for the `cloudsmith credential-helper docker` command.""" + +import io +import json +from unittest.mock import patch + +import pytest + +from ....cli.commands.credential_helper.docker import docker +from ....core.credentials.models import CredentialResult +from ....credential_helpers.custom_domains import get_cache_path, write_cache +from ....credential_helpers.docker.credentials import ( + get_credentials as helper_get_credentials, +) +from ....credential_helpers.docker.wrapper import main as docker_wrapper_main + + +class TestDockerCredentialHelper: + """Test suite for the Docker credential helper CLI command.""" + + def test_get_credentials_for_cloudsmith_io(self, runner): + """`*.cloudsmith.io` URLs should return credentials JSON on stdout.""" + fake_creds = {"Username": "token", "Secret": "k_abc"} + + with patch( + "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" + ) as mock_get: + mock_get.return_value = fake_creds + result = runner.invoke( + docker, input="docker.cloudsmith.io", catch_exceptions=False + ) + + assert result.exit_code == 0 + # stdout should contain the serialized JSON exactly as produced by the command. + assert json.dumps(fake_creds) in result.stdout + mock_get.assert_called_once() + # The first positional argument to get_credentials is the server URL. + called_args, _called_kwargs = mock_get.call_args + assert called_args[0] == "docker.cloudsmith.io" + + def test_refuses_non_cloudsmith_domain(self, runner): + """Non-Cloudsmith URLs should exit 1 with an error message on stderr.""" + with patch( + "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" + ) as mock_get: + mock_get.return_value = None + result = runner.invoke( + docker, input="evil.example.com", catch_exceptions=False + ) + + assert result.exit_code == 1 + assert "Unable to retrieve credentials" in result.output + mock_get.assert_called_once() + + def test_empty_stdin_exits_1(self, runner): + """Empty stdin should exit 1 with a descriptive error on stderr.""" + with patch( + "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" + ) as mock_get: + result = runner.invoke(docker, input="", catch_exceptions=False) + + assert result.exit_code == 1 + assert "No server URL provided" in result.output + # get_credentials should never be called when there is no URL. + mock_get.assert_not_called() + + def test_custom_domain_with_cached_response(self, tmp_path, monkeypatch): + """A cached custom-domain entry should authorise credential issuance. + + This exercises the helper-level `get_credentials` (not the click command) + so the on-disk custom-domain cache lookup runs end to end. The click + command's wiring is covered by the other tests in this class. + """ + # Point the cache base at a per-test temp directory. + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + # is_cloudsmith_domain reads CLOUDSMITH_ORG from the environment. + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + + # Seed the cache file at the path the helper will read from. + cache_path = get_cache_path("acme") + assert cache_path.parent.exists(), "get_cache_path should create the dir" + write_cache(cache_path, ["docker.acme.com"]) + + credential = CredentialResult(api_key="k_xyz", source_name="test") + + # Sentinel session; the cache hit means no HTTP call should be made. + class _BoomSession: + def get(self, *_args, **_kwargs): + raise AssertionError( + "Network call attempted despite a valid custom-domain cache" + ) + + result = helper_get_credentials( + "docker.acme.com", + credential=credential, + session=_BoomSession(), + api_host="https://api.cloudsmith.io", + ) + + assert result == {"Username": "token", "Secret": "k_xyz"} + + @pytest.mark.parametrize("operation", ["store", "erase"]) + def test_wrapper_read_only_operations_are_noops( + self, operation, monkeypatch, capsys + ): + """Docker's write operations should succeed without storing anything.""" + monkeypatch.setattr("sys.argv", ["docker-credential-cloudsmith", operation]) + monkeypatch.setattr("sys.stdin", io.StringIO('{"ServerURL":"example.com"}')) + + with pytest.raises(SystemExit) as exc: + docker_wrapper_main() + + assert exc.value.code == 0 + output = capsys.readouterr() + assert output.out == "" + assert output.err == "" + + def test_wrapper_list_returns_empty_json(self, monkeypatch, capsys): + """Docker's list operation should return an empty credential object.""" + monkeypatch.setattr("sys.argv", ["docker-credential-cloudsmith", "list"]) + + with pytest.raises(SystemExit) as exc: + docker_wrapper_main() + + assert exc.value.code == 0 + output = capsys.readouterr() + assert output.out == "{}\n" + assert output.err == "" diff --git a/cloudsmith_cli/core/cache_utils.py b/cloudsmith_cli/core/cache_utils.py new file mode 100644 index 00000000..1e65bdef --- /dev/null +++ b/cloudsmith_cli/core/cache_utils.py @@ -0,0 +1,35 @@ +# Copyright 2026 Cloudsmith Ltd +"""Shared utilities for on-disk credential and cache storage.""" + +from __future__ import annotations + +import json +import os +import tempfile +from typing import Any + + +def atomic_write_json(path: str | os.PathLike, data: Any, *, mode: int = 0o600) -> None: + """Atomically write JSON to a file with restrictive permissions. + + Writes to a sibling temp file, fsyncs, sets mode, then renames over the + destination. Concurrent readers never see a partial file. Temp file is + removed on error. Caller is responsible for ensuring the parent directory + exists. + """ + dest = os.fspath(path) + parent = os.path.dirname(dest) or "." + tmp_fd, tmp_path = tempfile.mkstemp(dir=parent, prefix=".tmp_", suffix=".json") + try: + with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: + json.dump(data, f) + f.flush() + os.fsync(f.fileno()) + os.chmod(tmp_path, mode) + os.replace(tmp_path, dest) + except (OSError, TypeError, ValueError): + try: + os.unlink(tmp_path) + except OSError: + pass + raise diff --git a/cloudsmith_cli/core/credentials/oidc/cache.py b/cloudsmith_cli/core/credentials/oidc/cache.py index 183a9615..4136543f 100644 --- a/cloudsmith_cli/core/credentials/oidc/cache.py +++ b/cloudsmith_cli/core/credentials/oidc/cache.py @@ -183,13 +183,13 @@ def _store_in_keyring(api_host: str, org: str, service_slug: str, data: dict) -> def _store_on_disk(api_host: str, org: str, service_slug: str, data: dict) -> None: """Store token on disk.""" + from ...cache_utils import atomic_write_json + cache_dir = _get_cache_dir() cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) try: - fd = os.open(cache_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) - with os.fdopen(fd, "w") as f: - json.dump(data, f) + atomic_write_json(cache_file, data) logger.debug( "Stored OIDC token on disk (expires_at=%s)", data.get("expires_at") ) diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index 8c7eb594..a9e0f787 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -90,4 +90,4 @@ def is_cloudsmith_domain( api_host=api_host, ) - return hostname in [d.lower() for d in custom_domains] + return hostname in {d.lower() for d in custom_domains if isinstance(d, str)} diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py index 079dc9e3..027cb510 100644 --- a/cloudsmith_cli/credential_helpers/custom_domains.py +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -12,6 +12,9 @@ import requests +from ..cli.config import get_default_config_path +from ..core.cache_utils import atomic_write_json + logger = logging.getLogger(__name__) # Cache custom domains for 1 hour @@ -21,13 +24,9 @@ def get_cache_dir() -> Path: """ Get the cache directory for custom domains. - - Returns: - Path to cache directory (e.g., ~/.cloudsmith/cache/custom_domains/) """ - home = Path.home() - cache_dir = home / ".cloudsmith" / "cache" / "custom_domains" - cache_dir.mkdir(parents=True, exist_ok=True) + cache_dir = Path(get_default_config_path()) / "custom_domains_cache" + cache_dir.mkdir(mode=0o700, parents=True, exist_ok=True) return cache_dir @@ -97,20 +96,13 @@ def read_cache(cache_path: Path) -> list[str] | None: def write_cache(cache_path: Path, domains: list[str]) -> None: - """ - Write custom domains to cache file. - - Args: - cache_path: Path to cache file - domains: List of domain strings to cache - """ + """Write custom domains to cache file.""" + data = { + "domains": domains, + "cached_at": time.time(), + } try: - data = { - "domains": domains, - "cached_at": time.time(), - } - with open(cache_path, "w", encoding="utf-8") as f: - json.dump(data, f) + atomic_write_json(cache_path, data) logger.debug("Wrote %d domains to cache: %s", len(domains), cache_path) except OSError as exc: logger.debug("Failed to write cache %s: %s", cache_path, exc) diff --git a/cloudsmith_cli/credential_helpers/docker/wrapper.py b/cloudsmith_cli/credential_helpers/docker/wrapper.py index 85f050ee..cc115455 100644 --- a/cloudsmith_cli/credential_helpers/docker/wrapper.py +++ b/cloudsmith_cli/credential_helpers/docker/wrapper.py @@ -3,7 +3,8 @@ Wrapper for docker-credential-cloudsmith. This is the entry point binary that Docker calls. It delegates to the main -cloudsmith credential-helper docker command. +cloudsmith credential-helper docker command for credential lookups and handles +read-only protocol operations locally. See: https://github.com/docker/docker-credential-helpers @@ -28,7 +29,7 @@ def main(): - erase: Erase credentials (not supported) - list: List credentials (not supported) - We only support 'get' and delegate to: cloudsmith credential-helper docker + The helper is read-only, so only 'get' returns Cloudsmith credentials. """ if len(sys.argv) < 2: print( @@ -56,13 +57,16 @@ def main(): file=sys.stderr, ) sys.exit(1) - elif operation in ("store", "erase", "list"): - print( - f"Error: Operation '{operation}' is not supported. " - "Only 'get' is available for Cloudsmith credential helper.", - file=sys.stderr, - ) - sys.exit(1) + elif operation in ("store", "erase"): + try: + if not sys.stdin.isatty(): + sys.stdin.read() + except (OSError, ValueError): + pass + sys.exit(0) + elif operation == "list": + print("{}") + sys.exit(0) else: print( f"Error: Unknown operation '{operation}'. " From 3afe390b0377b549818271d236425e509e350f9f Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Wed, 3 Jun 2026 09:41:15 +0100 Subject: [PATCH 07/21] fix: move to top level import --- cloudsmith_cli/core/credentials/oidc/cache.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudsmith_cli/core/credentials/oidc/cache.py b/cloudsmith_cli/core/credentials/oidc/cache.py index 4136543f..a0892ca0 100644 --- a/cloudsmith_cli/core/credentials/oidc/cache.py +++ b/cloudsmith_cli/core/credentials/oidc/cache.py @@ -13,6 +13,8 @@ import os import time +from ...cache_utils import atomic_write_json + logger = logging.getLogger(__name__) EXPIRY_MARGIN_SECONDS = 60 @@ -183,8 +185,6 @@ def _store_in_keyring(api_host: str, org: str, service_slug: str, data: dict) -> def _store_on_disk(api_host: str, org: str, service_slug: str, data: dict) -> None: """Store token on disk.""" - from ...cache_utils import atomic_write_json - cache_dir = _get_cache_dir() cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) From 5365f4bb998ea5ee9bc3e085de62fffba9e9ccbf Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Wed, 3 Jun 2026 15:30:49 +0100 Subject: [PATCH 08/21] fix: import level and add .com to CS domains --- cloudsmith_cli/credential_helpers/common.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index a9e0f787..d09da42c 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -7,6 +7,8 @@ import logging import os +from .custom_domains import get_custom_domains_for_org + logger = logging.getLogger(__name__) @@ -69,7 +71,11 @@ def is_cloudsmith_domain( return False # Standard Cloudsmith domains — no auth needed - if hostname == "cloudsmith.io" or hostname.endswith(".cloudsmith.io"): + if ( + hostname in ("cloudsmith.io", "cloudsmith.com") + or hostname.endswith(".cloudsmith.io") + or hostname.endswith(".cloudsmith.com") + ): return True # Custom domains require org + auth @@ -80,8 +86,6 @@ def is_cloudsmith_domain( if not api_key: return False - from .custom_domains import get_custom_domains_for_org - custom_domains = get_custom_domains_for_org( org, session=session, From 7e828655ccca8db8ba02b4c30da2569d3b4fb6dd Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 11:24:05 +0100 Subject: [PATCH 09/21] refactor: change custom domain calls to use sdk --- .../cli/commands/credential_helper/docker.py | 3 +- .../tests/commands/test_credential_helper.py | 149 +++++++++++++++++- cloudsmith_cli/core/api/orgs.py | 15 ++ cloudsmith_cli/credential_helpers/common.py | 6 +- .../credential_helpers/custom_domains.py | 100 ++++++------ .../credential_helpers/docker/credentials.py | 4 +- requirements.txt | 2 +- 7 files changed, 211 insertions(+), 68 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py index 3026e34b..7bd13e8b 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/docker.py +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -62,8 +62,7 @@ def docker(opts): credentials = get_credentials( server_url, credential=opts.credential, - session=opts.session, - api_host=opts.api_host or "https://api.cloudsmith.io", + api_host=opts.api_host, ) if not credentials: diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index 9c91465e..9bb31742 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -4,16 +4,25 @@ import json from unittest.mock import patch +import httpretty +import httpretty.core import pytest from ....cli.commands.credential_helper.docker import docker from ....core.credentials.models import CredentialResult -from ....credential_helpers.custom_domains import get_cache_path, write_cache +from ....credential_helpers.custom_domains import ( + get_cache_path, + get_custom_domains_for_org, + read_cache, + write_cache, +) from ....credential_helpers.docker.credentials import ( get_credentials as helper_get_credentials, ) from ....credential_helpers.docker.wrapper import main as docker_wrapper_main +API_HOST = "https://api.cloudsmith.io" + class TestDockerCredentialHelper: """Test suite for the Docker credential helper CLI command.""" @@ -86,17 +95,20 @@ def test_custom_domain_with_cached_response(self, tmp_path, monkeypatch): credential = CredentialResult(api_key="k_xyz", source_name="test") - # Sentinel session; the cache hit means no HTTP call should be made. - class _BoomSession: - def get(self, *_args, **_kwargs): - raise AssertionError( - "Network call attempted despite a valid custom-domain cache" - ) + # A cache hit must short-circuit before any SDK call. + def _boom(*_args, **_kwargs): + raise AssertionError( + "API call attempted despite a valid custom-domain cache" + ) + + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains", + _boom, + ) result = helper_get_credentials( "docker.acme.com", credential=credential, - session=_BoomSession(), api_host="https://api.cloudsmith.io", ) @@ -129,3 +141,124 @@ def test_wrapper_list_returns_empty_json(self, monkeypatch, capsys): output = capsys.readouterr() assert output.out == "{}\n" assert output.err == "" + + +class TestGetCustomDomainsForOrg: + """Exercise the SDK-backed custom-domains fetch path. + + These tests stub the v1 `GET /orgs/{org}/custom-domains/` endpoint that the + `cloudsmith_api` SDK calls. The on-disk cache base is redirected to a temp dir + per test. + """ + + @pytest.fixture(autouse=True) + def _cache_dir(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + # httpretty's fake socket has no shutdown(); urllib3 calls it during + # getresponse(). Stub it so requests succeed under httpretty. + monkeypatch.setattr( + httpretty.core.fakesock.socket, + "shutdown", + lambda self, how: None, + raising=False, + ) + + @httpretty.activate(allow_net_connect=False) + def test_success_extracts_hosts_and_caches(self): + """A 200 response yields the `.host` of each model and caches the result.""" + body = [ + {"host": "docker.acme.com", "slug_perm": "a"}, + {"host": "dl.acme.com", "slug_perm": "b"}, + {"host": "", "slug_perm": "c"}, # blank host is skipped + ] + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps(body), + status=200, + content_type="application/json", + ) + + domains = get_custom_domains_for_org("acme", api_key="k_abc", api_host=API_HOST) + + assert domains == ["docker.acme.com", "dl.acme.com"] + # Auth header proves the SDK auth path is exercised (X-Api-Key, not Bearer). + assert httpretty.last_request().headers.get("X-Api-Key") == "k_abc" + # Result is cached. + assert read_cache(get_cache_path("acme")) == ["docker.acme.com", "dl.acme.com"] + + @httpretty.activate(allow_net_connect=False) + def test_bearer_auth_uses_authorization_header(self): + """A bearer credential sends an Authorization: Bearer header.""" + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps([]), + status=200, + content_type="application/json", + ) + + get_custom_domains_for_org( + "acme", api_key="tok123", auth_type="bearer", api_host=API_HOST + ) + + assert httpretty.last_request().headers.get("Authorization") == "Bearer tok123" + + @httpretty.activate(allow_net_connect=False) + def test_404_caches_empty(self): + """A 404 returns [] and caches the empty result to avoid repeat lookups.""" + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps({"detail": "Not found."}), + status=404, + content_type="application/json", + ) + + assert get_custom_domains_for_org("acme", api_key="k", api_host=API_HOST) == [] + assert read_cache(get_cache_path("acme")) == [] + + @httpretty.activate(allow_net_connect=False) + def test_402_caches_empty(self): + """A 402 (feature not enabled) returns [] and caches the empty result.""" + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps({"detail": "Upgrade required."}), + status=402, + content_type="application/json", + ) + + assert get_custom_domains_for_org("acme", api_key="k", api_host=API_HOST) == [] + assert read_cache(get_cache_path("acme")) == [] + + @httpretty.activate(allow_net_connect=False) + def test_403_does_not_cache(self): + """A 403 returns [] but does NOT cache (may succeed later once authed).""" + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps({"detail": "Forbidden."}), + status=403, + content_type="application/json", + ) + + assert get_custom_domains_for_org("acme", api_key="k", api_host=API_HOST) == [] + assert read_cache(get_cache_path("acme")) is None + + @httpretty.activate(allow_net_connect=False) + def test_server_error_returns_empty_without_raising(self): + """A 500 returns [] without raising and without caching.""" + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps({"detail": "Boom."}), + status=500, + content_type="application/json", + ) + + assert get_custom_domains_for_org("acme", api_key="k", api_host=API_HOST) == [] + assert read_cache(get_cache_path("acme")) is None diff --git a/cloudsmith_cli/core/api/orgs.py b/cloudsmith_cli/core/api/orgs.py index 32077c8e..94fccd6f 100644 --- a/cloudsmith_cli/core/api/orgs.py +++ b/cloudsmith_cli/core/api/orgs.py @@ -13,6 +13,21 @@ def get_orgs_api(): return get_api_client(cloudsmith_api.OrgsApi) +def list_custom_domains(owner): + """List custom domain hostnames for an organization. + + Returns the list of configured custom-domain hostnames (the ``host`` + field of each :class:`cloudsmith_api.OrganizationCustomDomains` model). + """ + client = get_orgs_api() + + with catch_raise_api_exception(): + domains, _, headers = client.orgs_custom_domains_list_with_http_info(org=owner) + + ratelimits.maybe_rate_limit(client, headers) + return [domain.host for domain in domains if getattr(domain, "host", None)] + + def list_vulnerability_policies(owner, page, page_size): """List vulnerability policies in a namespace.""" client = get_orgs_api() diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index d09da42c..158b4c52 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -47,9 +47,7 @@ def extract_hostname(url): return hostname -def is_cloudsmith_domain( - url, session=None, api_key=None, auth_type="api_key", api_host=None -): +def is_cloudsmith_domain(url, api_key=None, auth_type="api_key", api_host=None): """ Check if a URL points to a Cloudsmith service. @@ -58,7 +56,6 @@ def is_cloudsmith_domain( Args: url: URL or hostname to check - session: Pre-configured requests.Session with proxy/SSL settings api_key: API key/token for authenticating custom domain lookups auth_type: "api_key" (X-Api-Key header) or "bearer" (Authorization: Bearer) api_host: Cloudsmith API host URL @@ -88,7 +85,6 @@ def is_cloudsmith_domain( custom_domains = get_custom_domains_for_org( org, - session=session, api_key=api_key, auth_type=auth_type, api_host=api_host, diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py index 027cb510..e4bfc191 100644 --- a/cloudsmith_cli/credential_helpers/custom_domains.py +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd """ Helper for discovering Cloudsmith custom domains. @@ -9,11 +10,14 @@ import logging import time from pathlib import Path - -import requests +from typing import Literal from ..cli.config import get_default_config_path +from ..core.api.exceptions import ApiException +from ..core.api.init import initialise_api +from ..core.api.orgs import list_custom_domains from ..core.cache_utils import atomic_write_json +from ..core.credentials.models import CredentialResult logger = logging.getLogger(__name__) @@ -110,7 +114,6 @@ def write_cache(cache_path: Path, domains: list[str]) -> None: def get_custom_domains_for_org( # pylint: disable=too-many-return-statements org: str, - session=None, api_key: str | None = None, auth_type: str = "api_key", api_host: str | None = None, @@ -120,13 +123,16 @@ def get_custom_domains_for_org( # pylint: disable=too-many-return-statements Results are cached on the filesystem for 1 hour to avoid excessive API calls. + Fetches the domains through the Cloudsmith SDK + (``OrgsApi.orgs_custom_domains_list``) via the ``core.api`` wrapper, so the API + host and auth handling stay consistent with the rest of the CLI. + Args: org: Organization slug - session: Pre-configured requests.Session with proxy/SSL settings. - If None, a plain requests session is used. api_key: Optional API key/token for authentication auth_type: "api_key" (uses X-Api-Key header) or "bearer" (uses Authorization: Bearer) - api_host: Cloudsmith API host URL. Defaults to https://api.cloudsmith.io. + api_host: Cloudsmith API host URL (including version). Taken from the SDK + configuration default when not provided. Returns: List of custom domain strings (e.g., ['docker.customer.com', 'dl.customer.com']) @@ -140,59 +146,55 @@ def get_custom_domains_for_org( # pylint: disable=too-many-return-statements logger.debug("Fetching custom domains from API for %s", org) - try: - if session is None: - session = requests.Session() - - headers = {} - if api_key: - if auth_type == "bearer": - headers["Authorization"] = f"Bearer {api_key}" - else: - headers["X-Api-Key"] = api_key - - host = api_host or "https://api.cloudsmith.io" - url = f"{host}/orgs/{org}/custom-domains/" + # The docker credential-helper command path only resolves credentials; it does + # not initialise the global SDK Configuration. Do so here using the resolved + # API key/token and host so the SDK client authenticates and targets the right + # host (no hard-coded host literal). + normalized_auth_type: Literal["api_key", "bearer"] = ( + "bearer" if auth_type == "bearer" else "api_key" + ) + credential = ( + CredentialResult( + api_key=api_key, + source_name="credential-helper", + auth_type=normalized_auth_type, + ) + if api_key + else None + ) + initialise_api(host=api_host, credential=credential) - response = session.get(url, headers=headers, timeout=10) - - if response.status_code in (401, 403): + try: + domains = list_custom_domains(org) + except ApiException as exc: + if exc.status in (401, 403): + # Don't cache auth failures - might work later once authenticated. logger.debug( "Custom domains API requires auth - assuming no custom domains for %s", org, ) - return [] # Don't cache 401/403 - might work later with auth + return [] - if response.status_code == 404: + if exc.status == 404: + # Cache empty result to avoid repeated lookups for a missing org. logger.debug("Organization %s not found or has no custom domains", org) - write_cache(cache_path, []) # Cache empty result to avoid repeated 404s + write_cache(cache_path, []) return [] - if response.status_code != 200: - logger.debug( - "Failed to fetch custom domains for %s: HTTP %d", - org, - response.status_code, - ) + if exc.status == 402: + # Custom domains product feature not enabled - treat as none. + logger.debug("Custom domains not enabled for %s", org) + write_cache(cache_path, []) return [] - data = response.json() - - # Expected format: [{"host": "docker.customer.com", ...}, ...] - domains = [] - if isinstance(data, list): - for item in data: - if isinstance(item, dict): - host = item.get("host") - if isinstance(host, str) and host: - domains.append(host) - - logger.debug("Fetched %d custom domains for %s", len(domains), org) - - write_cache(cache_path, domains) - - return domains - - except (requests.RequestException, ValueError) as exc: + logger.debug("Failed to fetch custom domains for %s: HTTP %s", org, exc.status) + return [] + except Exception as exc: # pylint: disable=broad-except + # Never raise into the credential-helper flow - any failure just means + # "not a custom domain". logger.debug("Error fetching custom domains: %s", exc) return [] + + logger.debug("Fetched %d custom domains for %s", len(domains), org) + write_cache(cache_path, domains) + return domains diff --git a/cloudsmith_cli/credential_helpers/docker/credentials.py b/cloudsmith_cli/credential_helpers/docker/credentials.py index 0180a6ed..f195ea59 100644 --- a/cloudsmith_cli/credential_helpers/docker/credentials.py +++ b/cloudsmith_cli/credential_helpers/docker/credentials.py @@ -8,7 +8,7 @@ from ..common import is_cloudsmith_domain -def get_credentials(server_url, credential=None, session=None, api_host=None): +def get_credentials(server_url, credential=None, api_host=None): """ Get credentials for a Cloudsmith Docker registry. @@ -18,7 +18,6 @@ def get_credentials(server_url, credential=None, session=None, api_host=None): Args: server_url: The Docker registry server URL credential: Pre-resolved CredentialResult from the provider chain - session: Pre-configured requests.Session with proxy/SSL settings api_host: Cloudsmith API host URL Returns: @@ -29,7 +28,6 @@ def get_credentials(server_url, credential=None, session=None, api_host=None): if not is_cloudsmith_domain( server_url, - session=session, api_key=credential.api_key, auth_type=getattr(credential, "auth_type", "api_key"), api_host=api_host, diff --git a/requirements.txt b/requirements.txt index 1b192af2..28fce239 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,7 +45,7 @@ click-didyoumean==0.3.1 # via cloudsmith-cli (setup.py) click-spinner==0.1.10 # via cloudsmith-cli (setup.py) -cloudsmith-api==2.0.25 +cloudsmith-api==2.0.27 # via cloudsmith-cli (setup.py) configparser==7.2.0 # via click-configfile From 7c157b176bcc9bdf61aa72b15d75f119b2d476c9 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 13:35:57 +0100 Subject: [PATCH 10/21] feat(credential-helper): add merge_json_file safe JSON writer Extends cache_utils.py with merge_json_file, a safe atomic JSON-merge writer for editing user-owned config files (e.g. ~/.docker/config.json). Supports read-or-empty fallback, in-place mutation, stable indent=2 serialisation, idempotent change detection, optional .bak backup, dry_run mode, parent-dir creation, and atomic temp+replace writes. Extracts _atomic_write_text as a shared private helper so atomic_write_json retains its public signature and behaviour. Adds 27 tests covering all nine specified behaviours (foreign-key preservation, missing file/parent-dir creation, backup semantics, idempotency, dry_run, malformed input, serialisation format, return value, and atomic_write_json round-trip + permissions). Co-Authored-By: Claude --- cloudsmith_cli/core/cache_utils.py | 141 ++++++- cloudsmith_cli/core/tests/test_cache_utils.py | 375 ++++++++++++++++++ 2 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 cloudsmith_cli/core/tests/test_cache_utils.py diff --git a/cloudsmith_cli/core/cache_utils.py b/cloudsmith_cli/core/cache_utils.py index 1e65bdef..c08e9a95 100644 --- a/cloudsmith_cli/core/cache_utils.py +++ b/cloudsmith_cli/core/cache_utils.py @@ -6,23 +6,20 @@ import json import os import tempfile +from collections.abc import Callable from typing import Any -def atomic_write_json(path: str | os.PathLike, data: Any, *, mode: int = 0o600) -> None: - """Atomically write JSON to a file with restrictive permissions. +def _atomic_write_text(dest: str, text: str, *, mode: int = 0o600) -> None: + """Atomically write *text* to *dest* using a sibling temp file. - Writes to a sibling temp file, fsyncs, sets mode, then renames over the - destination. Concurrent readers never see a partial file. Temp file is - removed on error. Caller is responsible for ensuring the parent directory - exists. + Caller is responsible for ensuring the parent directory exists. """ - dest = os.fspath(path) parent = os.path.dirname(dest) or "." tmp_fd, tmp_path = tempfile.mkstemp(dir=parent, prefix=".tmp_", suffix=".json") try: with os.fdopen(tmp_fd, "w", encoding="utf-8") as f: - json.dump(data, f) + f.write(text) f.flush() os.fsync(f.fileno()) os.chmod(tmp_path, mode) @@ -33,3 +30,131 @@ def atomic_write_json(path: str | os.PathLike, data: Any, *, mode: int = 0o600) except OSError: pass raise + + +def atomic_write_json(path: str | os.PathLike, data: Any, *, mode: int = 0o600) -> None: + """Atomically write JSON to a file with restrictive permissions. + + Writes to a sibling temp file, fsyncs, sets mode, then renames over the + destination. Concurrent readers never see a partial file. Temp file is + removed on error. Caller is responsible for ensuring the parent directory + exists. + """ + dest = os.fspath(path) + _atomic_write_text(dest, json.dumps(data), mode=mode) + + +def merge_json_file( + path: str | os.PathLike, + mutate: Callable[[dict], None], + *, + backup: bool = True, + dry_run: bool = False, + mode: int = 0o600, +) -> bool: + """Read a JSON object file, apply *mutate* in place, and atomically write it back. + + Parameters + ---------- + path: + Path to the JSON file (e.g. ``~/.docker/config.json``). + mutate: + Callable that receives the loaded ``dict`` and modifies it in place. + It must not return a value; changes are applied to the dict directly. + backup: + When ``True`` (default) and the file already existed and content will + change, copy the prior file to ``{path}.bak`` before writing. + dry_run: + When ``True``, perform the read + mutate + change-detection but make + **no** writes (no temp file, no ``.bak``, no replace). + mode: + File-permission bits for the written file (default ``0o600``). + + Returns + ------- + bool + ``True`` if the file content changed (or would change under + ``dry_run``), ``False`` otherwise. + + Notes + ----- + * If the file is missing, empty, or does not parse as a JSON object + (``dict``), the starting value is ``{}``. + * Key order is preserved — ``sort_keys`` is **not** used. + * The on-disk form is ``json.dumps(data, indent=2, ensure_ascii=False) + "\\n"``. + * Parent directory is created (mode ``0o700``) if absent. + + Concurrency + ----------- + This is a single-writer, install-time helper (used by + ``credential-helper install/uninstall``). The read-modify-write is + last-writer-wins and is **NOT** safe against concurrent writers mutating + the same file; do not use it on a hot path. The atomic replace guarantees + the file is never left partially written, but concurrent merges can drop + each other's changes. + """ + dest = os.fspath(path) + + # ------------------------------------------------------------------ + # 1. Read existing content + # ------------------------------------------------------------------ + existing_text: str | None = None + try: + with open(dest, encoding="utf-8") as f: + existing_text = f.read() + except FileNotFoundError: + existing_text = None + + # ------------------------------------------------------------------ + # 2. Parse → dict (treat missing/empty/non-dict/malformed as {}) + # ------------------------------------------------------------------ + data: dict = {} + if existing_text: + try: + parsed = json.loads(existing_text) + if isinstance(parsed, dict): + data = parsed + except (json.JSONDecodeError, ValueError): + pass + + # ------------------------------------------------------------------ + # 3. Mutate in place + # ------------------------------------------------------------------ + mutate(data) + + # ------------------------------------------------------------------ + # 4. Stable serialisation + change detection + # ------------------------------------------------------------------ + new_text = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + if existing_text is not None: + # Normalise existing content for comparison: if the file already has + # the exact canonical form we produce, treat as no-change. + no_change = new_text == existing_text + else: + no_change = False # file didn't exist → always a change + + if no_change: + return False + + if dry_run: + return True + + # ------------------------------------------------------------------ + # 5. Ensure parent directory exists + # ------------------------------------------------------------------ + parent = os.path.dirname(dest) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + + # ------------------------------------------------------------------ + # 6. Backup (only when file existed and content changes) + # ------------------------------------------------------------------ + if backup and existing_text is not None: + _atomic_write_text(dest + ".bak", existing_text, mode=0o600) + + # ------------------------------------------------------------------ + # 7. Atomic write + # ------------------------------------------------------------------ + _atomic_write_text(dest, new_text, mode=mode) + + return True diff --git a/cloudsmith_cli/core/tests/test_cache_utils.py b/cloudsmith_cli/core/tests/test_cache_utils.py new file mode 100644 index 00000000..15c32369 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_cache_utils.py @@ -0,0 +1,375 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for cloudsmith_cli.core.cache_utils.""" + +from __future__ import annotations + +import json +import os +import stat + +from cloudsmith_cli.core.cache_utils import atomic_write_json, merge_json_file + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _read_json(path: str) -> dict: + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _read_text(path: str) -> str: + with open(path, encoding="utf-8") as f: + return f.read() + + +def _perms(path: str) -> int: + return stat.S_IMODE(os.stat(path).st_mode) + + +# --------------------------------------------------------------------------- +# atomic_write_json — basic round-trip and permissions +# --------------------------------------------------------------------------- + + +class TestAtomicWriteJson: + def test_round_trip(self, tmp_path): + dest = str(tmp_path / "data.json") + data = {"key": "value", "nested": {"a": 1}} + atomic_write_json(dest, data) + assert _read_json(dest) == data + + def test_default_permissions(self, tmp_path): + dest = str(tmp_path / "data.json") + atomic_write_json(dest, {"x": 1}) + assert _perms(dest) == 0o600 + + def test_custom_permissions(self, tmp_path): + dest = str(tmp_path / "data.json") + atomic_write_json(dest, {"x": 1}, mode=0o644) + assert _perms(dest) == 0o644 + + def test_overwrites_existing(self, tmp_path): + dest = str(tmp_path / "data.json") + atomic_write_json(dest, {"v": 1}) + atomic_write_json(dest, {"v": 2}) + assert _read_json(dest) == {"v": 2} + + +# --------------------------------------------------------------------------- +# merge_json_file +# --------------------------------------------------------------------------- + + +def _add_cred_helper(host: str): + """Return a mutate function that adds a credHelpers entry.""" + + def mutate(data: dict) -> None: + data.setdefault("credHelpers", {})[host] = "cloudsmith" + + return mutate + + +class TestMergeJsonFileForeignKeyPreservation: + """Foreign-key preservation: only touched keys change.""" + + def test_existing_keys_preserved(self, tmp_path): + path = str(tmp_path / "config.json") + initial = { + "auths": {"registry.example.com": {"auth": "dXNlcjpwYXNz"}}, + "credHelpers": {"x.example.com": "y"}, + } + with open(path, "w", encoding="utf-8") as f: + json.dump(initial, f) + + changed = merge_json_file( + path, + _add_cred_helper("docker.cloudsmith.io"), + ) + + assert changed is True + result = _read_json(path) + assert result["auths"] == initial["auths"] + assert result["credHelpers"]["x.example.com"] == "y" + assert result["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + + def test_key_order_not_sorted(self, tmp_path): + """Keys must stay in insertion order, not sorted.""" + path = str(tmp_path / "config.json") + # Write with z-first order via json module (which preserves insertion order) + with open(path, "w", encoding="utf-8") as f: + json.dump({"zzz": 1, "aaa": 2}, f) + + def noop(data: dict) -> None: + data["new_key"] = 3 + + merge_json_file(path, noop) + text = _read_text(path) + assert text.index('"zzz"') < text.index('"aaa"'), "Key order must be preserved" + + +class TestMergeJsonFileCreatesMissingFile: + """Creates a new file starting from {} when the file is absent.""" + + def test_creates_file_when_missing(self, tmp_path): + path = str(tmp_path / "subdir" / "config.json") + changed = merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + assert changed is True + assert os.path.exists(path) + result = _read_json(path) + assert result == {"credHelpers": {"docker.cloudsmith.io": "cloudsmith"}} + + def test_creates_parent_directory(self, tmp_path): + path = str(tmp_path / "missing_dir" / "config.json") + assert not os.path.exists(os.path.dirname(path)) + merge_json_file(path, _add_cred_helper("x")) + assert os.path.isdir(os.path.dirname(path)) + + def test_parent_dir_permissions(self, tmp_path): + path = str(tmp_path / "newdir" / "config.json") + merge_json_file(path, _add_cred_helper("x")) + parent_perms = _perms(os.path.dirname(path)) + assert parent_perms == 0o700 + + def test_file_permissions_after_create(self, tmp_path): + path = str(tmp_path / "newdir" / "config.json") + merge_json_file(path, _add_cred_helper("x")) + assert _perms(path) == 0o600 + + +class TestMergeJsonFileBackup: + """Backup behaviour: .bak is created with prior content when writing.""" + + def test_backup_created_on_change(self, tmp_path): + path = str(tmp_path / "config.json") + initial = {"auths": {}} + with open(path, "w", encoding="utf-8") as f: + json.dump(initial, f) + + merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + + bak_path = path + ".bak" + assert os.path.exists(bak_path), ".bak file should exist after a change" + assert _read_json(bak_path) == initial + + def test_no_backup_when_file_missing(self, tmp_path): + path = str(tmp_path / "config.json") + merge_json_file(path, _add_cred_helper("x")) + assert not os.path.exists(path + ".bak") + + def test_no_backup_when_no_change(self, tmp_path): + path = str(tmp_path / "config.json") + with open(path, "w", encoding="utf-8") as f: + f.write(json.dumps({"credHelpers": {"x": "cloudsmith"}}, indent=2) + "\n") + + def noop_already_set(data: dict) -> None: + data.setdefault("credHelpers", {})["x"] = "cloudsmith" + + changed = merge_json_file(path, noop_already_set) + assert changed is False + assert not os.path.exists(path + ".bak") + + def test_backup_is_mode_0o600_regardless_of_source_perms(self, tmp_path): + """The .bak must always be written 0o600 even when the source is 0o644.""" + path = str(tmp_path / "config.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"auths": {}}, f) + os.chmod(path, 0o644) + + merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + + bak_path = path + ".bak" + assert os.path.exists(bak_path), ".bak must be created" + assert ( + _perms(bak_path) == 0o600 + ), f".bak perms should be 0o600, got {oct(_perms(bak_path))}" + + +class TestMergeJsonFileIdempotent: + """Running the same merge twice: second call returns False, no new .bak.""" + + def test_idempotent_returns_false_second_call(self, tmp_path): + path = str(tmp_path / "config.json") + mutate = _add_cred_helper("docker.cloudsmith.io") + + first = merge_json_file(path, mutate) + assert first is True + + second = merge_json_file(path, mutate) + assert second is False + + def test_idempotent_no_overwrite_bak(self, tmp_path): + path = str(tmp_path / "config.json") + initial = {"auths": {}} + with open(path, "w", encoding="utf-8") as f: + json.dump(initial, f) + + mutate = _add_cred_helper("docker.cloudsmith.io") + merge_json_file(path, mutate) # first: changes file, writes .bak + + bak_path = path + ".bak" + bak_mtime_after_first = os.path.getmtime(bak_path) + + merge_json_file(path, mutate) # second: no change + + bak_mtime_after_second = os.path.getmtime(bak_path) + assert ( + bak_mtime_after_first == bak_mtime_after_second + ), ".bak must not be refreshed" + + +class TestMergeJsonFileDryRun: + """dry_run=True: correct return value, no file written.""" + + def test_dry_run_returns_true_when_would_change(self, tmp_path): + path = str(tmp_path / "config.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({}, f) + + result = merge_json_file(path, _add_cred_helper("x"), dry_run=True) + assert result is True + + def test_dry_run_file_unchanged(self, tmp_path): + path = str(tmp_path / "config.json") + original_text = json.dumps({}) + "\n" + # Write exactly the content we'll compare against + with open(path, "w", encoding="utf-8") as f: + f.write(json.dumps({"existing": True}, indent=2) + "\n") + original_text = _read_text(path) + + merge_json_file(path, _add_cred_helper("x"), dry_run=True) + + assert _read_text(path) == original_text, "dry_run must not modify the file" + + def test_dry_run_no_bak_created(self, tmp_path): + path = str(tmp_path / "config.json") + with open(path, "w", encoding="utf-8") as f: + json.dump({"existing": True}, f) + + merge_json_file(path, _add_cred_helper("x"), dry_run=True) + assert not os.path.exists(path + ".bak") + + def test_dry_run_returns_false_when_no_change(self, tmp_path): + path = str(tmp_path / "config.json") + # Pre-populate so mutate produces no change + with open(path, "w", encoding="utf-8") as f: + f.write(json.dumps({"credHelpers": {"x": "cloudsmith"}}, indent=2) + "\n") + + def already_set(data: dict) -> None: + data.setdefault("credHelpers", {})["x"] = "cloudsmith" + + result = merge_json_file(path, already_set, dry_run=True) + assert result is False + + def test_dry_run_missing_file_no_creation(self, tmp_path): + path = str(tmp_path / "ghost" / "config.json") + result = merge_json_file(path, _add_cred_helper("x"), dry_run=True) + assert result is True + assert not os.path.exists(path) + assert not os.path.exists(os.path.dirname(path)) + + +class TestMergeJsonFileMalformedInput: + """Malformed file → treated as {}.""" + + def test_malformed_json_treated_as_empty(self, tmp_path): + path = str(tmp_path / "config.json") + with open(path, "w", encoding="utf-8") as f: + f.write("not json") + + changed = merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + assert changed is True + result = _read_json(path) + assert result == {"credHelpers": {"docker.cloudsmith.io": "cloudsmith"}} + + def test_empty_file_treated_as_empty_dict(self, tmp_path): + path = str(tmp_path / "config.json") + with open(path, "w", encoding="utf-8"): + pass # touch / create empty file + + merge_json_file(path, _add_cred_helper("x")) + result = _read_json(path) + assert "credHelpers" in result + + def test_json_array_treated_as_empty_dict(self, tmp_path): + path = str(tmp_path / "config.json") + with open(path, "w", encoding="utf-8") as f: + json.dump([1, 2, 3], f) + + merge_json_file(path, _add_cred_helper("x")) + result = _read_json(path) + assert isinstance(result, dict) + assert "credHelpers" in result + + +class TestMergeJsonFileStableSerialization: + """On-disk form is json.dumps(data, indent=2, ensure_ascii=False) + newline.""" + + def test_output_format(self, tmp_path): + path = str(tmp_path / "config.json") + merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + text = _read_text(path) + expected = json.dumps( + {"credHelpers": {"docker.cloudsmith.io": "cloudsmith"}}, + indent=2, + ensure_ascii=False, + ) + assert text == expected + "\n" + + def test_trailing_newline(self, tmp_path): + path = str(tmp_path / "config.json") + merge_json_file(path, _add_cred_helper("x")) + text = _read_text(path) + assert text.endswith("\n") + + def test_non_ascii_host_raw_utf8_not_escaped(self, tmp_path): + """Non-ASCII chars must be written as raw UTF-8, not \\uXXXX escapes.""" + path = str(tmp_path / "config.json") + unicode_host = "café.docker.example.com" + mutate = _add_cred_helper(unicode_host) + + # First call: file is created (content changes → True) + first = merge_json_file(path, mutate) + assert first is True + + # The written file must contain the raw Unicode character + with open(path, "rb") as fh: + raw_bytes = fh.read() + assert "café".encode() in raw_bytes, "expected raw UTF-8, not \\uXXXX" + assert ( + b"\\u" not in raw_bytes + ), "must not use JSON unicode escapes for non-ASCII" + + # Second call: identical mutate → no change (idempotent) + bak_path = path + ".bak" + bak_mtime_before = ( + os.path.getmtime(bak_path) if os.path.exists(bak_path) else None + ) + + second = merge_json_file(path, mutate) + assert second is False + + # .bak must not have been touched on the no-op call + if bak_mtime_before is not None: + assert ( + os.path.getmtime(bak_path) == bak_mtime_before + ), ".bak must not refresh" + else: + assert not os.path.exists(bak_path), ".bak must not be created on no-op" + + +class TestMergeJsonFileReturnValue: + """Return True on change, False on no-change.""" + + def test_returns_true_on_actual_write(self, tmp_path): + path = str(tmp_path / "config.json") + result = merge_json_file(path, _add_cred_helper("x")) + assert result is True + + def test_returns_false_on_no_change(self, tmp_path): + path = str(tmp_path / "config.json") + mutate = _add_cred_helper("x") + merge_json_file(path, mutate) + result = merge_json_file(path, mutate) + assert result is False From 477132beffe4de74b08b157958d022d6ef1777ac Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 13:53:33 +0100 Subject: [PATCH 11/21] feat(credential-helper): structured custom-domain discovery + format filter Reshape the custom-domain layer to return typed CustomDomain dataclass records (host, backend_kind, enabled, validated) instead of bare host strings. Add BackendKind IntEnum mirroring the server enum. Expose get_custom_domains() and get_format_domains() so callers can filter by format. Switch list_custom_domains() in core/api/orgs.py to return to_dict() records following the repo pattern. Tighten exception handling to ApiException-only (no bare except). Rewire is_cloudsmith_domain to use enabled+validated check. Update tests for the new structured API. Co-Authored-By: Claude --- .../tests/commands/test_credential_helper.py | 342 +++++++++++++++++- cloudsmith_cli/core/api/orgs.py | 16 +- cloudsmith_cli/credential_helpers/backends.py | 41 +++ cloudsmith_cli/credential_helpers/common.py | 18 +- .../credential_helpers/custom_domains.py | 136 +++++-- 5 files changed, 489 insertions(+), 64 deletions(-) create mode 100644 cloudsmith_cli/credential_helpers/backends.py diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index 9bb31742..992222ca 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -10,9 +10,12 @@ from ....cli.commands.credential_helper.docker import docker from ....core.credentials.models import CredentialResult +from ....credential_helpers.backends import BackendKind from ....credential_helpers.custom_domains import ( + CustomDomain, get_cache_path, - get_custom_domains_for_org, + get_custom_domains, + get_format_domains, read_cache, write_cache, ) @@ -91,7 +94,14 @@ def test_custom_domain_with_cached_response(self, tmp_path, monkeypatch): # Seed the cache file at the path the helper will read from. cache_path = get_cache_path("acme") assert cache_path.parent.exists(), "get_cache_path should create the dir" - write_cache(cache_path, ["docker.acme.com"]) + write_cache( + cache_path, + [ + CustomDomain( + host="docker.acme.com", backend_kind=6, enabled=True, validated=True + ) + ], + ) credential = CredentialResult(api_key="k_xyz", source_name="test") @@ -143,7 +153,23 @@ def test_wrapper_list_returns_empty_json(self, monkeypatch, capsys): assert output.err == "" -class TestGetCustomDomainsForOrg: +class TestBackendKind: + """Spot-check BackendKind enum values.""" + + def test_docker_is_6(self): + assert BackendKind.DOCKER == 6 + + def test_npm_is_9(self): + assert BackendKind.NPM == 9 + + def test_deb_is_0(self): + assert BackendKind.DEB == 0 + + def test_default_is_99(self): + assert BackendKind.DEFAULT == 99 + + +class TestGetCustomDomains: """Exercise the SDK-backed custom-domains fetch path. These tests stub the v1 `GET /orgs/{org}/custom-domains/` endpoint that the @@ -167,12 +193,30 @@ def _cache_dir(self, tmp_path, monkeypatch): ) @httpretty.activate(allow_net_connect=False) - def test_success_extracts_hosts_and_caches(self): - """A 200 response yields the `.host` of each model and caches the result.""" + def test_success_builds_records_and_caches(self): + """A 200 response builds CustomDomain records and caches them.""" body = [ - {"host": "docker.acme.com", "slug_perm": "a"}, - {"host": "dl.acme.com", "slug_perm": "b"}, - {"host": "", "slug_perm": "c"}, # blank host is skipped + { + "host": "docker.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": True, + }, + { + "host": "dl.acme.com", + "backend_kind": None, + "domain_type": 0, + "enabled": True, + "validated": True, + }, + { + "host": "", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": True, + }, # blank host is skipped ] httpretty.register_uri( httpretty.GET, @@ -182,13 +226,47 @@ def test_success_extracts_hosts_and_caches(self): content_type="application/json", ) - domains = get_custom_domains_for_org("acme", api_key="k_abc", api_host=API_HOST) + domains = get_custom_domains("acme", api_key="k_abc", api_host=API_HOST) - assert domains == ["docker.acme.com", "dl.acme.com"] + assert len(domains) == 2 + assert domains[0] == CustomDomain( + host="docker.acme.com", backend_kind=6, enabled=True, validated=True + ) + assert domains[1] == CustomDomain( + host="dl.acme.com", backend_kind=None, enabled=True, validated=True + ) # Auth header proves the SDK auth path is exercised (X-Api-Key, not Bearer). assert httpretty.last_request().headers.get("X-Api-Key") == "k_abc" - # Result is cached. - assert read_cache(get_cache_path("acme")) == ["docker.acme.com", "dl.acme.com"] + + @httpretty.activate(allow_net_connect=False) + def test_cache_round_trip(self): + """Fetched records are cached; a second call returns the same records from cache.""" + body = [ + { + "host": "docker.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": True, + }, + ] + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps(body), + status=200, + content_type="application/json", + ) + + first = get_custom_domains("acme", api_key="k_abc", api_host=API_HOST) + + # Verify the cache contains structured records. + cached = read_cache(get_cache_path("acme")) + assert cached is not None + assert cached == first + assert cached[0].backend_kind == 6 + assert cached[0].enabled is True + assert cached[0].validated is True @httpretty.activate(allow_net_connect=False) def test_bearer_auth_uses_authorization_header(self): @@ -201,7 +279,7 @@ def test_bearer_auth_uses_authorization_header(self): content_type="application/json", ) - get_custom_domains_for_org( + get_custom_domains( "acme", api_key="tok123", auth_type="bearer", api_host=API_HOST ) @@ -218,7 +296,7 @@ def test_404_caches_empty(self): content_type="application/json", ) - assert get_custom_domains_for_org("acme", api_key="k", api_host=API_HOST) == [] + assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] assert read_cache(get_cache_path("acme")) == [] @httpretty.activate(allow_net_connect=False) @@ -232,7 +310,7 @@ def test_402_caches_empty(self): content_type="application/json", ) - assert get_custom_domains_for_org("acme", api_key="k", api_host=API_HOST) == [] + assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] assert read_cache(get_cache_path("acme")) == [] @httpretty.activate(allow_net_connect=False) @@ -246,11 +324,11 @@ def test_403_does_not_cache(self): content_type="application/json", ) - assert get_custom_domains_for_org("acme", api_key="k", api_host=API_HOST) == [] + assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] assert read_cache(get_cache_path("acme")) is None @httpretty.activate(allow_net_connect=False) - def test_server_error_returns_empty_without_raising(self): + def test_server_error_returns_empty_without_caching(self): """A 500 returns [] without raising and without caching.""" httpretty.register_uri( httpretty.GET, @@ -260,5 +338,233 @@ def test_server_error_returns_empty_without_raising(self): content_type="application/json", ) - assert get_custom_domains_for_org("acme", api_key="k", api_host=API_HOST) == [] + assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] + assert read_cache(get_cache_path("acme")) is None + + @httpretty.activate(allow_net_connect=False) + def test_401_does_not_cache(self): + """A 401 returns [] but does NOT cache (may succeed later once authed).""" + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps({"detail": "Unauthorized."}), + status=401, + content_type="application/json", + ) + + assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] assert read_cache(get_cache_path("acme")) is None + + def test_legacy_string_cache_is_a_miss(self, tmp_path): + """A cache file with a string-list 'domains' (old format) returns None.""" + import time + + cache_path = get_cache_path("acme") + legacy_data = { + "domains": ["docker.acme.com"], + "cached_at": time.time(), + } + cache_path.write_text(json.dumps(legacy_data), encoding="utf-8") + assert read_cache(cache_path) is None + + def test_empty_domains_cache_is_a_hit(self, tmp_path): + """A cache file with 'domains': [] returns [] (valid cached 'no domains').""" + import time + + cache_path = get_cache_path("acme") + empty_data = { + "domains": [], + "cached_at": time.time(), + } + cache_path.write_text(json.dumps(empty_data), encoding="utf-8") + assert read_cache(cache_path) == [] + + +class TestGetFormatDomains: + """Test get_format_domains filters by backend_kind, enabled, and validated.""" + + @pytest.fixture(autouse=True) + def _cache_dir(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + monkeypatch.setattr( + httpretty.core.fakesock.socket, + "shutdown", + lambda self, how: None, + raising=False, + ) + + @httpretty.activate(allow_net_connect=False) + def test_returns_only_enabled_validated_docker_hosts(self): + """Only Docker domains that are both enabled and validated are returned.""" + body = [ + # Should be included: Docker, enabled, validated + { + "host": "docker.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": True, + }, + # Excluded: different backend_kind (NPM = 9) + { + "host": "npm.acme.com", + "backend_kind": 9, + "domain_type": 1, + "enabled": True, + "validated": True, + }, + # Excluded: Docker but not enabled + { + "host": "docker2.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": False, + "validated": True, + }, + # Excluded: Docker but not validated + { + "host": "docker3.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": False, + }, + # Excluded: backend_kind is None (download domain) + { + "host": "dl.acme.com", + "backend_kind": None, + "domain_type": 0, + "enabled": True, + "validated": True, + }, + ] + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps(body), + status=200, + content_type="application/json", + ) + + hosts = get_format_domains( + "acme", BackendKind.DOCKER, api_key="k", api_host=API_HOST + ) + + assert hosts == ["docker.acme.com"] + + +class TestIsCloudsmithDomain: + """Test is_cloudsmith_domain with standard and custom domains.""" + + @pytest.fixture(autouse=True) + def _cache_dir(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + + def test_standard_cloudsmith_io_true(self): + """Standard *.cloudsmith.io domains are true without any API call.""" + from ....credential_helpers.common import is_cloudsmith_domain + + assert is_cloudsmith_domain("docker.cloudsmith.io") is True + assert is_cloudsmith_domain("dl.cloudsmith.io") is True + + def test_standard_cloudsmith_com_true(self): + """Standard *.cloudsmith.com domains are true without any API call.""" + from ....credential_helpers.common import is_cloudsmith_domain + + assert is_cloudsmith_domain("docker.cloudsmith.com") is True + + def test_non_cloudsmith_false(self): + """Unrelated hostnames return False.""" + from ....credential_helpers.common import is_cloudsmith_domain + + assert is_cloudsmith_domain("evil.example.com") is False + + def test_custom_enabled_validated_host_true(self, tmp_path, monkeypatch): + """An enabled+validated custom domain in cache returns True.""" + from ....credential_helpers.common import is_cloudsmith_domain + + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="docker.acme.com", + backend_kind=6, + enabled=True, + validated=True, + ) + ], + ) + + assert ( + is_cloudsmith_domain( + "docker.acme.com", + api_key="k_abc", + api_host=API_HOST, + ) + is True + ) + + def test_custom_disabled_host_false(self, tmp_path, monkeypatch): + """A disabled custom domain is not treated as a Cloudsmith domain.""" + from ....credential_helpers.common import is_cloudsmith_domain + + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="docker.acme.com", + backend_kind=6, + enabled=False, + validated=True, + ) + ], + ) + + assert ( + is_cloudsmith_domain( + "docker.acme.com", + api_key="k_abc", + api_host=API_HOST, + ) + is False + ) + + def test_custom_enabled_not_validated_host_false(self, tmp_path, monkeypatch): + """An enabled but unvalidated custom domain is not a Cloudsmith domain.""" + from ....credential_helpers.common import is_cloudsmith_domain + + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="docker.acme.com", + backend_kind=6, + enabled=True, + validated=False, + ) + ], + ) + + assert ( + is_cloudsmith_domain( + "docker.acme.com", + api_key="k_abc", + api_host=API_HOST, + ) + is False + ) diff --git a/cloudsmith_cli/core/api/orgs.py b/cloudsmith_cli/core/api/orgs.py index 94fccd6f..1b730401 100644 --- a/cloudsmith_cli/core/api/orgs.py +++ b/cloudsmith_cli/core/api/orgs.py @@ -14,18 +14,20 @@ def get_orgs_api(): def list_custom_domains(owner): - """List custom domain hostnames for an organization. - - Returns the list of configured custom-domain hostnames (the ``host`` - field of each :class:`cloudsmith_api.OrganizationCustomDomains` model). - """ + """List custom domains for an organization (raw SDK model dicts).""" client = get_orgs_api() with catch_raise_api_exception(): - domains, _, headers = client.orgs_custom_domains_list_with_http_info(org=owner) + ( + domains, + _, + headers, + ) = client.orgs_custom_domains_list_with_http_info( # pylint: disable=no-member + org=owner + ) ratelimits.maybe_rate_limit(client, headers) - return [domain.host for domain in domains if getattr(domain, "host", None)] + return [domain.to_dict() for domain in domains] def list_vulnerability_policies(owner, page, page_size): diff --git a/cloudsmith_cli/credential_helpers/backends.py b/cloudsmith_cli/credential_helpers/backends.py new file mode 100644 index 00000000..d6c32b25 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/backends.py @@ -0,0 +1,41 @@ +# Copyright 2026 Cloudsmith Ltd +"""Backend-kind enumeration for credential helpers.""" + +from enum import IntEnum + + +class BackendKind(IntEnum): + """Mirror of the server-side BackendKind enum (cloudsmith/package/enums.py).""" + + DEB = 0 + RPM = 1 + RUBY = 2 + PYTHON = 3 + MAVEN = 4 + BOWER = 5 + DOCKER = 6 + RAW = 7 + CHOCOLATEY = 8 + NPM = 9 + NUGET = 10 + VAGRANT = 11 + COMPOSER = 12 + ALPINE = 13 + HELM = 14 + CONAN = 15 + CARGO = 16 + LUAROCKS = 17 + CRAN = 18 + GO = 19 + DART = 20 + COCOAPODS = 21 + TERRAFORM = 22 + P2 = 23 + CONDA = 24 + HEX = 25 + SWIFT = 26 + HUGGINGFACE = 27 + GENERIC = 28 + VSX = 29 + MCP = 30 + DEFAULT = 99 diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index 158b4c52..341b0d88 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -7,7 +7,7 @@ import logging import os -from .custom_domains import get_custom_domains_for_org +from .custom_domains import get_custom_domains logger = logging.getLogger(__name__) @@ -83,11 +83,11 @@ def is_cloudsmith_domain(url, api_key=None, auth_type="api_key", api_host=None): if not api_key: return False - custom_domains = get_custom_domains_for_org( - org, - api_key=api_key, - auth_type=auth_type, - api_host=api_host, - ) - - return hostname in {d.lower() for d in custom_domains if isinstance(d, str)} + hosts = { + d.host.lower() + for d in get_custom_domains( + org, api_key=api_key, auth_type=auth_type, api_host=api_host + ) + if d.enabled and d.validated + } + return hostname in hosts diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py index e4bfc191..582a8d01 100644 --- a/cloudsmith_cli/credential_helpers/custom_domains.py +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -9,6 +9,7 @@ import json import logging import time +from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -25,6 +26,16 @@ CACHE_TTL_SECONDS = 3600 +@dataclass(frozen=True) +class CustomDomain: + """A structured Cloudsmith custom domain record.""" + + host: str + backend_kind: int | None + enabled: bool + validated: bool + + def get_cache_dir() -> Path: """ Get the cache directory for custom domains. @@ -70,7 +81,7 @@ def is_cache_valid(cache_path: Path) -> bool: return False -def read_cache(cache_path: Path) -> list[str] | None: +def read_cache(cache_path: Path) -> list[CustomDomain] | None: """ Read custom domains from cache file. @@ -78,7 +89,7 @@ def read_cache(cache_path: Path) -> list[str] | None: cache_path: Path to cache file Returns: - List of domain strings or None if cache invalid/missing + List of CustomDomain records or None if cache invalid/missing """ if not is_cache_valid(cache_path): return None @@ -89,20 +100,48 @@ def read_cache(cache_path: Path) -> list[str] | None: if isinstance(data, dict) and "domains" in data: domains = data["domains"] if isinstance(domains, list): + # Detect legacy format: non-empty list of strings (old build stored + # domains as plain strings, not dicts). Treat as a cache miss so the + # caller re-fetches and rewrites in the current dict format. + if domains and not any(isinstance(d, dict) for d in domains): + logger.debug( + "Stale string-format cache detected at %s, treating as miss", + cache_path, + ) + return None + + records = [ + CustomDomain( + host=d["host"], + backend_kind=d.get("backend_kind"), + enabled=bool(d.get("enabled", False)), + validated=bool(d.get("validated", False)), + ) + for d in domains + if isinstance(d, dict) and d.get("host") + ] logger.debug( - "Read %d domains from cache: %s", len(domains), cache_path + "Read %d domains from cache: %s", len(records), cache_path ) - return domains + return records except (OSError, json.JSONDecodeError) as exc: logger.debug("Failed to read cache %s: %s", cache_path, exc) return None -def write_cache(cache_path: Path, domains: list[str]) -> None: +def write_cache(cache_path: Path, domains: list[CustomDomain]) -> None: """Write custom domains to cache file.""" data = { - "domains": domains, + "domains": [ + { + "host": d.host, + "backend_kind": d.backend_kind, + "enabled": d.enabled, + "validated": d.validated, + } + for d in domains + ], "cached_at": time.time(), } try: @@ -112,21 +151,18 @@ def write_cache(cache_path: Path, domains: list[str]) -> None: logger.debug("Failed to write cache %s: %s", cache_path, exc) -def get_custom_domains_for_org( # pylint: disable=too-many-return-statements +def get_custom_domains( # pylint: disable=too-many-return-statements org: str, + *, api_key: str | None = None, auth_type: str = "api_key", api_host: str | None = None, -) -> list[str]: +) -> list[CustomDomain]: """ Fetch custom domains for a Cloudsmith organization. Results are cached on the filesystem for 1 hour to avoid excessive API calls. - Fetches the domains through the Cloudsmith SDK - (``OrgsApi.orgs_custom_domains_list``) via the ``core.api`` wrapper, so the API - host and auth handling stay consistent with the rest of the CLI. - Args: org: Organization slug api_key: Optional API key/token for authentication @@ -135,21 +171,24 @@ def get_custom_domains_for_org( # pylint: disable=too-many-return-statements configuration default when not provided. Returns: - List of custom domain strings (e.g., ['docker.customer.com', 'dl.customer.com']) - Empty list if API call fails or org has no custom domains + List of CustomDomain records. + Empty list if API call fails or org has no custom domains. + + Note: + Only ``ApiException`` is handled here (per-status). Network-layer errors + (DNS/timeout/SSL/urllib3) are intentionally NOT caught — they propagate to + the caller. The credential-helper protocol boundary (the click command) and + the installer are responsible for catching broadly and refusing gracefully, + so the library stays free of bare ``except Exception`` (reviewer feedback). """ cache_path = get_cache_path(org) - cached_domains = read_cache(cache_path) - if cached_domains is not None: + cached = read_cache(cache_path) + if cached is not None: logger.debug("Using cached custom domains for %s", org) - return cached_domains + return cached logger.debug("Fetching custom domains from API for %s", org) - # The docker credential-helper command path only resolves credentials; it does - # not initialise the global SDK Configuration. Do so here using the resolved - # API key/token and host so the SDK client authenticates and targets the right - # host (no hard-coded host literal). normalized_auth_type: Literal["api_key", "bearer"] = ( "bearer" if auth_type == "bearer" else "api_key" ) @@ -165,7 +204,7 @@ def get_custom_domains_for_org( # pylint: disable=too-many-return-statements initialise_api(host=api_host, credential=credential) try: - domains = list_custom_domains(org) + raw_domains = list_custom_domains(org) except ApiException as exc: if exc.status in (401, 403): # Don't cache auth failures - might work later once authenticated. @@ -189,12 +228,49 @@ def get_custom_domains_for_org( # pylint: disable=too-many-return-statements logger.debug("Failed to fetch custom domains for %s: HTTP %s", org, exc.status) return [] - except Exception as exc: # pylint: disable=broad-except - # Never raise into the credential-helper flow - any failure just means - # "not a custom domain". - logger.debug("Error fetching custom domains: %s", exc) - return [] - logger.debug("Fetched %d custom domains for %s", len(domains), org) - write_cache(cache_path, domains) - return domains + records = [ + CustomDomain( + host=d["host"], + backend_kind=d.get("backend_kind"), + enabled=bool(d.get("enabled", False)), + validated=bool(d.get("validated", False)), + ) + for d in raw_domains + if d.get("host") + ] + + logger.debug("Fetched %d custom domains for %s", len(records), org) + write_cache(cache_path, records) + return records + + +def get_format_domains( + org: str, + backend_kind: int, + *, + api_key: str | None = None, + auth_type: str = "api_key", + api_host: str | None = None, +) -> list[str]: + """ + Return enabled and validated custom domain hostnames for a specific backend format. + + Args: + org: Organization slug + backend_kind: BackendKind int value (e.g. BackendKind.DOCKER == 6) + api_key: Optional API key/token for authentication + auth_type: "api_key" or "bearer" + api_host: Cloudsmith API host URL + + Returns: + List of hostnames that are enabled, validated, and match the given backend_kind. + """ + domains = get_custom_domains( + org, api_key=api_key, auth_type=auth_type, api_host=api_host + ) + return [ + d.host + for d in domains + if d.backend_kind == int(backend_kind) and d.enabled and d.validated + ] From 455c78756d1a670a6f53245f8e86dc02e2e84b57 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 14:14:49 +0100 Subject: [PATCH 12/21] refactor(credential-helper): extract docker runtime + boundary error handling Move Docker credential-helper protocol logic from the click command into a transport-light `credential_helpers/docker/runtime.py`. The command is now a thin shim that calls `execute()` and passes stdout/stderr/exit-code back to the caller. The D17 protocol-boundary broad-except in `_execute_get` ensures that network/SDK errors degrade to a clean exit-1 refusal rather than crashing `docker pull`/`push`. `credentials.py` is removed; its logic lives in `runtime.py`. Co-Authored-By: Claude --- .../cli/commands/credential_helper/docker.py | 63 ++--- .../tests/commands/test_credential_helper.py | 217 +++++++++++++++++- .../credential_helpers/docker/__init__.py | 4 +- .../credential_helpers/docker/credentials.py | 37 --- .../credential_helpers/docker/runtime.py | 115 ++++++++++ 5 files changed, 347 insertions(+), 89 deletions(-) delete mode 100644 cloudsmith_cli/credential_helpers/docker/credentials.py create mode 100644 cloudsmith_cli/credential_helpers/docker/runtime.py diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py index 7bd13e8b..d6d4e8e6 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/docker.py +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -6,31 +6,32 @@ See: https://github.com/docker/docker-credential-helpers """ -import json import sys import click -from ....credential_helpers.docker import get_credentials +from ....credential_helpers.docker import execute from ...decorators import common_api_auth_options, resolve_credentials @click.command() +@click.argument("operation", required=False, default="get") @common_api_auth_options @resolve_credentials -def docker(opts): +def docker(opts, operation): """ Docker credential helper for Cloudsmith registries. - Reads a Docker registry server URL from stdin and returns credentials in JSON format. - This command implements the 'get' operation of the Docker credential helper protocol. + Reads a Docker registry server URL from stdin and returns credentials in + JSON format. Implements the full Docker credential helper protocol + (get/store/erase/list). - Only provides credentials for Cloudsmith Docker registries: `*.cloudsmith.io` - and any custom domains configured for the organization (requires CLOUDSMITH_ORG - and a valid API key/token). + Provides credentials for all Cloudsmith Docker registries: ``*.cloudsmith.io``, + ``*.cloudsmith.com``, and any custom domains configured for the organisation + (requires CLOUDSMITH_ORG and a valid API key/token). Input (stdin): - Server URL as plain text (e.g., "docker.cloudsmith.io") + Server URL as plain text (e.g. "docker.cloudsmith.io") Output (stdout): JSON: {"Username": "token", "Secret": ""} @@ -42,41 +43,23 @@ def docker(opts): Examples: # Manual testing $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker - {"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."} # Called by Docker via wrapper $ echo "docker.cloudsmith.io" | docker-credential-cloudsmith get - {"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."} Environment variables: CLOUDSMITH_API_KEY: API key for authentication (optional) - CLOUDSMITH_ORG: Organization slug (required for custom domain support) + CLOUDSMITH_ORG: Organisation slug (required for custom domain support) """ - try: - server_url = sys.stdin.read().strip() - - if not server_url: - click.echo("Error: No server URL provided on stdin", err=True) - sys.exit(1) - - credentials = get_credentials( - server_url, - credential=opts.credential, - api_host=opts.api_host, - ) - - if not credentials: - click.echo( - "Error: Unable to retrieve credentials. " - "Provide credentials via the CLOUDSMITH_API_KEY environment variable, " - "credentials.ini, the system keyring, or an OIDC service. " - "Verify current authentication with `cloudsmith whoami --verbose`.", - err=True, - ) - sys.exit(1) - - click.echo(json.dumps(credentials)) - - except OSError as e: - click.echo(f"Error: {e}", err=True) - sys.exit(1) + exit_code, stdout, stderr = execute( + operation, + sys.stdin, + credential=opts.credential, + api_host=opts.api_host, + ) + + if stdout is not None: + click.echo(stdout) + if stderr is not None: + click.echo(stderr, err=True) + sys.exit(exit_code) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index 992222ca..212954be 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -19,7 +19,8 @@ read_cache, write_cache, ) -from ....credential_helpers.docker.credentials import ( +from ....credential_helpers.docker.runtime import ( + execute, get_credentials as helper_get_credentials, ) from ....credential_helpers.docker.wrapper import main as docker_wrapper_main @@ -27,6 +28,159 @@ API_HOST = "https://api.cloudsmith.io" +class TestDockerRuntime: + """Unit tests for the transport-light runtime (execute + get_credentials).""" + + # ------------------------------------------------------------------ + # execute – get operation + # ------------------------------------------------------------------ + + def test_execute_get_success_returns_json(self): + """execute get → (0, json_string, None) when get_credentials returns a dict.""" + fake_creds = {"Username": "token", "Secret": "k_abc"} + stdin = io.StringIO("docker.cloudsmith.io") + + with patch( + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" + ) as mock_get: + mock_get.return_value = fake_creds + code, stdout, stderr = execute("get", stdin) + + assert code == 0 + assert json.loads(stdout) == fake_creds + assert stderr is None + + def test_execute_get_refusal_returns_exit_1(self): + """execute get → (1, None, refusal_msg) when get_credentials returns None.""" + stdin = io.StringIO("evil.example.com") + + with patch( + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" + ) as mock_get: + mock_get.return_value = None + code, stdout, stderr = execute("get", stdin) + + assert code == 1 + assert stdout is None + assert "Unable to retrieve credentials" in stderr + + def test_execute_get_empty_stdin_returns_exit_1(self): + """execute get with empty stdin → (1, None, 'No server URL...').""" + stdin = io.StringIO("") + code, stdout, stderr = execute("get", stdin) + + assert code == 1 + assert stdout is None + assert "No server URL provided" in stderr + + def test_execute_get_exception_is_caught_at_boundary(self): + """D17: a network/SDK error inside get_credentials must NOT escape execute. + + The protocol boundary degrades to a clean refusal (exit 1) so that + docker pull/push never sees a Python traceback. + """ + stdin = io.StringIO("docker.cloudsmith.io") + + with patch( + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" + ) as mock_get: + mock_get.side_effect = RuntimeError("boom") + code, stdout, stderr = execute("get", stdin) + + assert code == 1 + assert stdout is None + assert "Unable to retrieve credentials" in stderr + + def test_execute_get_broken_pipe_stdin_is_caught_at_boundary(self): + """A broken-pipe OSError from stdin.read() must not escape execute. + + The widened boundary covers the stdin read, so a broken pipe degrades + to a clean refusal (exit 1) rather than propagating an exception. + """ + from ....credential_helpers.docker.runtime import _REFUSAL_MESSAGE + + class BrokenPipeStdin: + def read(self): + raise OSError("broken pipe") + + credential = CredentialResult(api_key="k_abc", source_name="test") + code, stdout, stderr = execute( + "get", BrokenPipeStdin(), credential=credential, api_host=None + ) + + assert code == 1 + assert stdout is None + assert stderr == _REFUSAL_MESSAGE + + # ------------------------------------------------------------------ + # execute – write/no-op operations + # ------------------------------------------------------------------ + + @pytest.mark.parametrize("operation", ["store", "erase"]) + def test_execute_store_erase_returns_0_no_output(self, operation): + """store and erase drain stdin and return (0, None, None).""" + stdin = io.StringIO('{"ServerURL": "docker.cloudsmith.io"}') + code, stdout, stderr = execute(operation, stdin) + + assert code == 0 + assert stdout is None + assert stderr is None + + def test_execute_list_returns_empty_json_object(self): + """list always returns (0, '{}', None).""" + stdin = io.StringIO("") + code, stdout, stderr = execute("list", stdin) + + assert code == 0 + assert stdout == "{}" + assert stderr is None + + def test_execute_unknown_operation_returns_exit_1(self): + """An unrecognised operation name returns (1, None, error_message).""" + stdin = io.StringIO("") + code, stdout, stderr = execute("frobnicate", stdin) + + assert code == 1 + assert stdout is None + assert "Unknown operation" in stderr + assert "frobnicate" in stderr + + # ------------------------------------------------------------------ + # get_credentials + # ------------------------------------------------------------------ + + def test_get_credentials_returns_dict_for_cloudsmith_domain(self): + """get_credentials returns username+secret for a Cloudsmith domain.""" + credential = CredentialResult(api_key="k_xyz", source_name="test") + + with patch( + "cloudsmith_cli.credential_helpers.docker.runtime.is_cloudsmith_domain" + ) as mock_is: + mock_is.return_value = True + result = helper_get_credentials( + "docker.cloudsmith.io", credential=credential + ) + + assert result == {"Username": "token", "Secret": "k_xyz"} + + def test_get_credentials_returns_none_when_no_credential(self): + """get_credentials returns None when credential is absent.""" + result = helper_get_credentials("docker.cloudsmith.io", credential=None) + assert result is None + + def test_get_credentials_returns_none_for_non_cloudsmith_domain(self): + """get_credentials returns None when is_cloudsmith_domain is False.""" + credential = CredentialResult(api_key="k_xyz", source_name="test") + + with patch( + "cloudsmith_cli.credential_helpers.docker.runtime.is_cloudsmith_domain" + ) as mock_is: + mock_is.return_value = False + result = helper_get_credentials("evil.example.com", credential=credential) + + assert result is None + + class TestDockerCredentialHelper: """Test suite for the Docker credential helper CLI command.""" @@ -35,29 +189,51 @@ def test_get_credentials_for_cloudsmith_io(self, runner): fake_creds = {"Username": "token", "Secret": "k_abc"} with patch( - "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" ) as mock_get: mock_get.return_value = fake_creds result = runner.invoke( - docker, input="docker.cloudsmith.io", catch_exceptions=False + docker, + args=["get"], + input="docker.cloudsmith.io", + catch_exceptions=False, ) assert result.exit_code == 0 - # stdout should contain the serialized JSON exactly as produced by the command. assert json.dumps(fake_creds) in result.stdout mock_get.assert_called_once() - # The first positional argument to get_credentials is the server URL. called_args, _called_kwargs = mock_get.call_args assert called_args[0] == "docker.cloudsmith.io" + def test_no_arg_defaults_to_get(self, runner): + """Invoking docker with no OPERATION argument defaults to 'get'.""" + fake_creds = {"Username": "token", "Secret": "k_abc"} + + with patch( + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" + ) as mock_get: + mock_get.return_value = fake_creds + result = runner.invoke( + docker, + args=[], + input="docker.cloudsmith.io", + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert json.dumps(fake_creds) in result.stdout + def test_refuses_non_cloudsmith_domain(self, runner): """Non-Cloudsmith URLs should exit 1 with an error message on stderr.""" with patch( - "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" ) as mock_get: mock_get.return_value = None result = runner.invoke( - docker, input="evil.example.com", catch_exceptions=False + docker, + args=["get"], + input="evil.example.com", + catch_exceptions=False, ) assert result.exit_code == 1 @@ -67,15 +243,36 @@ def test_refuses_non_cloudsmith_domain(self, runner): def test_empty_stdin_exits_1(self, runner): """Empty stdin should exit 1 with a descriptive error on stderr.""" with patch( - "cloudsmith_cli.cli.commands.credential_helper.docker.get_credentials" + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" ) as mock_get: - result = runner.invoke(docker, input="", catch_exceptions=False) + result = runner.invoke( + docker, args=["get"], input="", catch_exceptions=False + ) assert result.exit_code == 1 assert "No server URL provided" in result.output - # get_credentials should never be called when there is no URL. mock_get.assert_not_called() + @pytest.mark.parametrize("operation", ["store", "erase"]) + def test_store_erase_exit_0_no_output(self, runner, operation): + """store and erase exit 0 and produce no output.""" + result = runner.invoke( + docker, + args=[operation], + input='{"ServerURL": "docker.cloudsmith.io"}', + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert result.output == "" + + def test_list_prints_empty_json(self, runner): + """list exits 0 and prints '{}'.""" + result = runner.invoke(docker, args=["list"], input="", catch_exceptions=False) + + assert result.exit_code == 0 + assert "{}" in result.output + def test_custom_domain_with_cached_response(self, tmp_path, monkeypatch): """A cached custom-domain entry should authorise credential issuance. diff --git a/cloudsmith_cli/credential_helpers/docker/__init__.py b/cloudsmith_cli/credential_helpers/docker/__init__.py index a96d42d1..f5a865db 100644 --- a/cloudsmith_cli/credential_helpers/docker/__init__.py +++ b/cloudsmith_cli/credential_helpers/docker/__init__.py @@ -1,3 +1,3 @@ -from .credentials import get_credentials +from .runtime import execute, get_credentials -__all__ = ["get_credentials"] +__all__ = ["execute", "get_credentials"] diff --git a/cloudsmith_cli/credential_helpers/docker/credentials.py b/cloudsmith_cli/credential_helpers/docker/credentials.py deleted file mode 100644 index f195ea59..00000000 --- a/cloudsmith_cli/credential_helpers/docker/credentials.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -Docker credential helper logic for Cloudsmith. - -This module provides functions for retrieving credentials for Docker registries -using the existing Cloudsmith credential provider chain (OIDC, API keys, config, keyring). -""" - -from ..common import is_cloudsmith_domain - - -def get_credentials(server_url, credential=None, api_host=None): - """ - Get credentials for a Cloudsmith Docker registry. - - Verifies the URL is a Cloudsmith registry (including custom domains) - and returns credentials if available. - - Args: - server_url: The Docker registry server URL - credential: Pre-resolved CredentialResult from the provider chain - api_host: Cloudsmith API host URL - - Returns: - dict: Credentials with 'Username' and 'Secret' keys, or None - """ - if not credential or not credential.api_key: - return None - - if not is_cloudsmith_domain( - server_url, - api_key=credential.api_key, - auth_type=getattr(credential, "auth_type", "api_key"), - api_host=api_host, - ): - return None - - return {"Username": "token", "Secret": credential.api_key} diff --git a/cloudsmith_cli/credential_helpers/docker/runtime.py b/cloudsmith_cli/credential_helpers/docker/runtime.py new file mode 100644 index 00000000..4200bbce --- /dev/null +++ b/cloudsmith_cli/credential_helpers/docker/runtime.py @@ -0,0 +1,115 @@ +# Copyright 2026 Cloudsmith Ltd +""" +Docker credential helper runtime. + +Transport-light protocol logic for the Docker credential helper protocol. +This module is intentionally free of Click/sys imports so it can be unit-tested +without invoking the CLI machinery. + +See: https://github.com/docker/docker-credential-helpers +""" + +import json +import logging + +from ..common import is_cloudsmith_domain + +logger = logging.getLogger(__name__) + +_REFUSAL_MESSAGE = ( + "Error: Unable to retrieve credentials. " + "Provide credentials via the CLOUDSMITH_API_KEY environment variable, " + "credentials.ini, the system keyring, or an OIDC service. " + "Verify current authentication with `cloudsmith whoami --verbose`." +) + + +def get_credentials(server_url, credential=None, api_host=None): + """ + Get credentials for a Cloudsmith Docker registry. + + Verifies the URL is a Cloudsmith registry (including custom domains) + and returns credentials if available. + + Args: + server_url: The Docker registry server URL + credential: Pre-resolved CredentialResult from the provider chain + api_host: Cloudsmith API host URL + + Returns: + dict: Credentials with 'Username' and 'Secret' keys, or None + """ + if not credential or not credential.api_key: + return None + + if not is_cloudsmith_domain( + server_url, + api_key=credential.api_key, + auth_type=getattr(credential, "auth_type", "api_key"), + api_host=api_host, + ): + return None + + return {"Username": "token", "Secret": credential.api_key} + + +def _execute_get(stdin, credential, api_host) -> tuple[int, str | None, str | None]: + """Handle the 'get' operation of the Docker credential helper protocol.""" + try: + server_url = stdin.read().strip() + if not server_url: + return (1, None, "Error: No server URL provided on stdin") + + creds = get_credentials(server_url, credential=credential, api_host=api_host) + if creds is None: + return (1, None, _REFUSAL_MESSAGE) + + return (0, json.dumps(creds), None) + except Exception as exc: # pylint: disable=broad-except + # Protocol boundary: a credential helper must never crash `docker pull`/`push`. + # Covers: broken-pipe OSError from stdin.read(), network/SDK errors from + # get_credentials, and TypeError from json.dumps — all degrade to a clean + # refusal (exit 1), not a traceback. + # This is the ONLY intentional broad except in this feature. + # (Exception does not catch KeyboardInterrupt/SystemExit, which is correct.) + logger.debug("docker credential-helper get failed: %s", exc, exc_info=True) + return (1, None, _REFUSAL_MESSAGE) + + +def execute( + operation, stdin, credential=None, api_host=None +) -> tuple[int, str | None, str | None]: + """ + Execute a Docker credential helper protocol operation. + + Args: + operation: One of 'get', 'store', 'erase', 'list' + stdin: A file-like object to read the server URL from (for 'get') + credential: Pre-resolved CredentialResult from the provider chain + api_host: Cloudsmith API host URL + + Returns: + A (exit_code, stdout_text, stderr_text) tuple. Either text value may + be None if there is nothing to write to that stream. + """ + if operation in ("store", "erase"): + # Drain stdin to keep Docker happy; guard against tty/pipe errors. + try: + if not stdin.isatty(): + stdin.read() + except (OSError, ValueError, AttributeError): + pass + return (0, None, None) + + if operation == "list": + return (0, "{}", None) + + if operation == "get": + return _execute_get(stdin, credential, api_host) + + return ( + 1, + None, + f"Error: Unknown operation '{operation}'. " + "Valid operations: get, store, erase, list", + ) From 0633088d77ea6d7c21928b2ec864c6df2a0a6010 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 14:35:10 +0100 Subject: [PATCH 13/21] feat(credential-helper): add install/uninstall/list command + launchers Replaces the old Python wrapper entry-point with an on-PATH shell launcher written by `cloudsmith credential-helper install docker`. The installer patches ~/.docker/config.json via merge_json_file (preserving foreign keys, atomic write, .bak backup) and supports --domain, --bin-dir, and --dry-run. Adds launchers.py (write/remove/resolve_bin_dir/is_on_path), docker/installer.py (DockerInstaller), and cli/commands/credential_helper/manage.py (install/uninstall/list commands). Removes credential_helpers/docker/wrapper.py and its setup.py console_scripts entry point. Co-Authored-By: Claude --- .../commands/credential_helper/__init__.py | 13 +- .../cli/commands/credential_helper/docker.py | 2 +- .../cli/commands/credential_helper/manage.py | 191 ++++++ .../tests/commands/test_credential_helper.py | 29 - .../test_credential_helper_install.py | 583 ++++++++++++++++++ .../credential_helpers/docker/installer.py | 250 ++++++++ .../credential_helpers/docker/wrapper.py | 80 --- .../credential_helpers/launchers.py | 147 +++++ setup.py | 1 - 9 files changed, 1182 insertions(+), 114 deletions(-) create mode 100644 cloudsmith_cli/cli/commands/credential_helper/manage.py create mode 100644 cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py create mode 100644 cloudsmith_cli/credential_helpers/docker/installer.py delete mode 100644 cloudsmith_cli/credential_helpers/docker/wrapper.py create mode 100644 cloudsmith_cli/credential_helpers/launchers.py diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index 10727bf4..bccbeb6d 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -9,6 +9,7 @@ from ..main import main from .docker import docker as docker_cmd +from .manage import install_cmd, list_cmd, uninstall_cmd @click.group() @@ -17,15 +18,21 @@ def credential_helper(): Credential helpers for package managers. These commands provide credentials for package managers like Docker. - They are typically called by wrapper binaries - (e.g., docker-credential-cloudsmith) or used directly for debugging. + Use ``install`` to set up the on-PATH launcher and configure the package + manager automatically, or run the runtime command directly for debugging. Examples: - # Test Docker credential helper + # Install Docker credential helper + $ cloudsmith credential-helper install docker + + # Test Docker credential helper directly $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker """ credential_helper.add_command(docker_cmd, name="docker") +credential_helper.add_command(install_cmd, name="install") +credential_helper.add_command(uninstall_cmd, name="uninstall") +credential_helper.add_command(list_cmd, name="list") main.add_command(credential_helper, name="credential-helper") diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py index d6d4e8e6..adc05202 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/docker.py +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -44,7 +44,7 @@ def docker(opts, operation): # Manual testing $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker - # Called by Docker via wrapper + # Called by Docker via launcher $ echo "docker.cloudsmith.io" | docker-credential-cloudsmith get Environment variables: diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py new file mode 100644 index 00000000..d95d851a --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -0,0 +1,191 @@ +# Copyright 2026 Cloudsmith Ltd +"""Install/uninstall/list commands for credential helpers. + +Provides ``credential-helper install``, ``credential-helper uninstall``, and +``credential-helper list`` to manage the on-PATH launcher binary and the +``config.json`` entries for each supported credential helper. +""" + +from __future__ import annotations + +import sys + +import click + +from ....credential_helpers.docker.installer import DockerInstaller + +# --------------------------------------------------------------------------- +# Helper registry — extend here when new helpers are added +# --------------------------------------------------------------------------- + +_INSTALLERS: dict[str, type] = { + "docker": DockerInstaller, +} + + +def _get_installer(name: str): + """Return an instantiated installer for *name*, or exit with a clear error. + + Parameters + ---------- + name: + The helper name as supplied by the user (e.g. ``"docker"``). + + Returns + ------- + DockerInstaller + An instance of the appropriate installer class. + + Raises + ------ + SystemExit + If *name* is not in :data:`_INSTALLERS`. + """ + cls = _INSTALLERS.get(name) + if cls is None: + available = ", ".join(sorted(_INSTALLERS)) + click.echo( + f"Error: unknown helper {name!r}. Available helpers: {available}", + err=True, + ) + sys.exit(1) + return cls() + + +# --------------------------------------------------------------------------- +# install +# --------------------------------------------------------------------------- + + +@click.command("install") +@click.argument("helper") +@click.option( + "--bin-dir", default=None, help="Directory to install the launcher binary." +) +@click.option( + "--domain", + "domains", + multiple=True, + help="Additional registry hostname to configure (repeatable).", +) +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show what would be done without making any changes.", +) +def install_cmd( + helper: str, bin_dir: str | None, domains: tuple[str, ...], dry_run: bool +) -> None: + """Install a credential helper launcher and configure the package manager. + + HELPER is the name of the credential helper to install (e.g. ``docker``). + + Examples: + + \b + # Install Docker credential helper + $ cloudsmith credential-helper install docker + + \b + # Install with a custom domain + $ cloudsmith credential-helper install docker --domain my.registry.example.com + + \b + # Preview without making changes + $ cloudsmith credential-helper install docker --dry-run + """ + installer = _get_installer(helper) + try: + actions = installer.install(bin_dir=bin_dir, domains=domains, dry_run=dry_run) + except OSError as exc: + raise click.ClickException( + f"Failed to install {helper!r} credential helper: {exc}" + ) + + if dry_run: + click.echo("Dry run — no changes will be made:") + for action in actions: + if action.startswith("WARNING"): + click.secho(f" {action}" if dry_run else action, err=True, fg="yellow") + else: + click.echo(f" {action}" if dry_run else action) + + +# --------------------------------------------------------------------------- +# uninstall +# --------------------------------------------------------------------------- + + +@click.command("uninstall") +@click.argument("helper") +@click.option( + "--dry-run", + is_flag=True, + default=False, + help="Show what would be done without making any changes.", +) +def uninstall_cmd(helper: str, dry_run: bool) -> None: + """Uninstall a credential helper launcher and remove its config entries. + + HELPER is the name of the credential helper to uninstall (e.g. ``docker``). + + Examples: + + \b + # Uninstall Docker credential helper + $ cloudsmith credential-helper uninstall docker + + \b + # Preview without making changes + $ cloudsmith credential-helper uninstall docker --dry-run + """ + installer = _get_installer(helper) + try: + actions = installer.uninstall(dry_run=dry_run) + except OSError as exc: + raise click.ClickException( + f"Failed to uninstall {helper!r} credential helper: {exc}" + ) + + if dry_run: + click.echo("Dry run — no changes will be made:") + for action in actions: + if action.startswith("WARNING"): + click.secho(f" {action}" if dry_run else action, err=True, fg="yellow") + else: + click.echo(f" {action}" if dry_run else action) + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +@click.command("list") +def list_cmd() -> None: + """List available credential helpers and their installation status. + + Shows which helpers are available, whether their launcher binary is + present on PATH, and which registry hosts they are configured for. + + Example: + + \b + $ cloudsmith credential-helper list + """ + for name, cls in sorted(_INSTALLERS.items()): + installer = cls() + st = installer.status() + launcher = st.get("launcher") + hosts = st.get("hosts", []) + + click.echo(f"{name} ({installer.summary})") + if launcher: + click.echo(f" launcher : {launcher}") + else: + click.echo(" launcher : not installed") + if hosts: + click.echo(f" hosts : {', '.join(hosts)}") + else: + click.echo(" hosts : none configured") diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index 212954be..1ecec75a 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -23,7 +23,6 @@ execute, get_credentials as helper_get_credentials, ) -from ....credential_helpers.docker.wrapper import main as docker_wrapper_main API_HOST = "https://api.cloudsmith.io" @@ -321,34 +320,6 @@ def _boom(*_args, **_kwargs): assert result == {"Username": "token", "Secret": "k_xyz"} - @pytest.mark.parametrize("operation", ["store", "erase"]) - def test_wrapper_read_only_operations_are_noops( - self, operation, monkeypatch, capsys - ): - """Docker's write operations should succeed without storing anything.""" - monkeypatch.setattr("sys.argv", ["docker-credential-cloudsmith", operation]) - monkeypatch.setattr("sys.stdin", io.StringIO('{"ServerURL":"example.com"}')) - - with pytest.raises(SystemExit) as exc: - docker_wrapper_main() - - assert exc.value.code == 0 - output = capsys.readouterr() - assert output.out == "" - assert output.err == "" - - def test_wrapper_list_returns_empty_json(self, monkeypatch, capsys): - """Docker's list operation should return an empty credential object.""" - monkeypatch.setattr("sys.argv", ["docker-credential-cloudsmith", "list"]) - - with pytest.raises(SystemExit) as exc: - docker_wrapper_main() - - assert exc.value.code == 0 - output = capsys.readouterr() - assert output.out == "{}\n" - assert output.err == "" - class TestBackendKind: """Spot-check BackendKind enum values.""" diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py new file mode 100644 index 00000000..f313c7c1 --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -0,0 +1,583 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for credential-helper install/uninstall/list commands and launchers.""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path +from unittest.mock import patch + +import click.testing +import pytest + +from ....credential_helpers.docker.installer import DockerInstaller +from ....credential_helpers.launchers import ( + is_on_path, + remove_launcher, + resolve_bin_dir, + write_launcher, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def runner(): + """Return a CliRunner.""" + return click.testing.CliRunner() + + +# --------------------------------------------------------------------------- +# write_launcher / remove_launcher — Unix +# --------------------------------------------------------------------------- + + +class TestWriteLauncherUnix: + """Tests for write_launcher on Unix (os.name == 'posix').""" + + def test_content_is_correct(self, tmp_path): + """Launcher content is exactly the exec-forwarding shell script.""" + dest = write_launcher( + tmp_path, + "docker-credential-cloudsmith", + "cloudsmith credential-helper docker", + ) + expected = '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n' + assert dest.read_text(encoding="utf-8") == expected + + def test_mode_is_755(self, tmp_path): + """Launcher is created with mode 0o755.""" + dest = write_launcher( + tmp_path, + "docker-credential-cloudsmith", + "cloudsmith credential-helper docker", + ) + mode = dest.stat().st_mode + assert stat.S_IMODE(mode) == 0o755 + + def test_returns_path_without_extension(self, tmp_path): + """Returned path has no extension on Unix.""" + dest = write_launcher( + tmp_path, "my-helper", "cloudsmith credential-helper docker" + ) + assert dest.name == "my-helper" + + def test_creates_bin_dir_if_absent(self, tmp_path): + """write_launcher creates bin_dir if it does not yet exist.""" + new_dir = tmp_path / "newdir" / "bin" + assert not new_dir.exists() + write_launcher(new_dir, "my-helper", "cloudsmith credential-helper docker") + assert new_dir.is_dir() + + +class TestRemoveLauncherUnix: + """Tests for remove_launcher on Unix.""" + + def test_returns_true_when_file_existed(self, tmp_path): + """remove_launcher returns True when a file was present and deleted.""" + write_launcher( + tmp_path, + "docker-credential-cloudsmith", + "cloudsmith credential-helper docker", + ) + result = remove_launcher(tmp_path, "docker-credential-cloudsmith") + assert result is True + + def test_returns_false_when_file_absent(self, tmp_path): + """remove_launcher returns False when no launcher file exists.""" + result = remove_launcher(tmp_path, "docker-credential-cloudsmith") + assert result is False + + def test_file_is_gone_after_remove(self, tmp_path): + """After remove_launcher the file no longer exists on disk.""" + write_launcher( + tmp_path, + "docker-credential-cloudsmith", + "cloudsmith credential-helper docker", + ) + remove_launcher(tmp_path, "docker-credential-cloudsmith") + assert not (tmp_path / "docker-credential-cloudsmith").exists() + + +# --------------------------------------------------------------------------- +# write_launcher — Windows simulation +# --------------------------------------------------------------------------- + + +class TestWriteLauncherWindows: + """Tests for write_launcher when os.name == 'nt'. + + On non-Windows systems we cannot instantiate a WindowsPath, so we test + the string content by inspecting the file via the returned path string and + verifying the name suffix—the actual file is created with a str join rather + than a WindowsPath object on macOS/Linux CI. + """ + + def test_creates_cmd_file(self, tmp_path, monkeypatch): + """On Windows, write_launcher creates a .cmd file.""" + import cloudsmith_cli.credential_helpers.launchers as _launchers_mod + + monkeypatch.setattr(_launchers_mod.os, "name", "nt") + dest = write_launcher( + tmp_path, + "docker-credential-cloudsmith", + "cloudsmith credential-helper docker", + ) + assert str(dest).endswith(".cmd") + + def test_cmd_content(self, tmp_path, monkeypatch): + """On Windows, .cmd content is byte-exact for correct Docker credential parsing.""" + import cloudsmith_cli.credential_helpers.launchers as _launchers_mod + + monkeypatch.setattr(_launchers_mod.os, "name", "nt") + dest = write_launcher( + tmp_path, + "docker-credential-cloudsmith", + "cloudsmith credential-helper docker", + ) + # Read raw bytes to avoid universal-newline translation on macOS/Linux + raw = Path(str(dest)).read_bytes() + assert raw == b"@echo off\r\ncloudsmith credential-helper docker %*\r\n" + + +# --------------------------------------------------------------------------- +# resolve_bin_dir +# --------------------------------------------------------------------------- + + +class TestResolveBinDir: + """Tests for resolve_bin_dir resolution logic.""" + + def test_override_is_respected(self, tmp_path): + """An explicit override path is returned as-is.""" + result = resolve_bin_dir(str(tmp_path)) + assert result == tmp_path + + def test_falls_back_to_user_bin_when_no_writable_cloudsmith( + self, tmp_path, monkeypatch + ): + """Falls back to ~/.local/bin when cloudsmith is not found and bin is not writable.""" + import cloudsmith_cli.credential_helpers.launchers as _launchers_mod + + # Patch shutil.which inside the launchers module + monkeypatch.setattr(_launchers_mod.shutil, "which", lambda _name: None) + # Patch Path.home() to point at tmp_path + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setattr(_launchers_mod.os, "name", "posix") + # Make os.access always return False so no fallback dir looks writable + monkeypatch.setattr(_launchers_mod.os, "access", lambda _path, _mode: False) + + result = resolve_bin_dir() + assert result == tmp_path / ".local" / "bin" + + def test_falls_back_to_windows_user_bin(self, tmp_path, monkeypatch): + """On Windows, falls back to %LOCALAPPDATA%/Cloudsmith/bin.""" + import cloudsmith_cli.credential_helpers.launchers as _launchers_mod + + monkeypatch.setattr(_launchers_mod.shutil, "which", lambda _name: None) + monkeypatch.setattr(_launchers_mod.os, "name", "nt") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + # Make no directory look writable + monkeypatch.setattr(_launchers_mod.os, "access", lambda _path, _mode: False) + + result = resolve_bin_dir() + # Compare normalised paths to handle cross-platform separator differences + # when testing Windows branch on macOS/Linux + result_str = str(result).replace("\\", "/") + expected_str = str(tmp_path / "Cloudsmith" / "bin").replace("\\", "/") + assert result_str == expected_str + + +# --------------------------------------------------------------------------- +# is_on_path +# --------------------------------------------------------------------------- + + +class TestIsOnPath: + """Tests for is_on_path.""" + + def test_directory_on_path(self, tmp_path, monkeypatch): + """A directory that appears in PATH is detected correctly.""" + monkeypatch.setenv("PATH", str(tmp_path)) + assert is_on_path(tmp_path) is True + + def test_directory_not_on_path(self, tmp_path, monkeypatch): + """A directory absent from PATH returns False.""" + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") + assert is_on_path(tmp_path) is False + + def test_normalisation_handles_trailing_slash(self, tmp_path, monkeypatch): + """Trailing slashes in PATH entries are normalised correctly.""" + monkeypatch.setenv("PATH", str(tmp_path) + os.sep) + assert is_on_path(tmp_path) is True + + +# --------------------------------------------------------------------------- +# DockerInstaller.install +# --------------------------------------------------------------------------- + + +class TestDockerInstallerInstall: + """Tests for DockerInstaller.install.""" + + def _make_docker_config(self, docker_dir: Path, data: dict) -> Path: + """Write a config.json to *docker_dir* and return its path.""" + docker_dir.mkdir(parents=True, exist_ok=True) + cfg = docker_dir / "config.json" + cfg.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + return cfg + + def test_sets_default_host(self, tmp_path, monkeypatch): + """install sets credHelpers[docker.cloudsmith.io]=cloudsmith.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir)) + + cfg = json.loads((docker_dir / "config.json").read_text()) + assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + + def test_sets_additional_domain(self, tmp_path, monkeypatch): + """install also sets credHelpers for --domain entries.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir), domains=("my.registry.example.com",)) + + cfg = json.loads((docker_dir / "config.json").read_text()) + assert cfg["credHelpers"]["my.registry.example.com"] == "cloudsmith" + + def test_preserves_foreign_keys(self, tmp_path, monkeypatch): + """Foreign keys in auths and credHelpers are not touched.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + # Seed a config with existing foreign data + self._make_docker_config( + docker_dir, + { + "auths": {"ghcr.io": {"auth": "dG9rZW4="}}, + "credHelpers": {"ghcr.io": "gh"}, + }, + ) + + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir)) + + cfg = json.loads((docker_dir / "config.json").read_text()) + assert cfg["auths"] == {"ghcr.io": {"auth": "dG9rZW4="}} + assert cfg["credHelpers"]["ghcr.io"] == "gh" + + def test_writes_launcher(self, tmp_path, monkeypatch): + """install writes a launcher script to bin_dir.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir)) + + launcher = bin_dir / "docker-credential-cloudsmith" + assert launcher.exists() + + def test_creates_bak_file(self, tmp_path, monkeypatch): + """install creates a .bak backup when config.json already exists.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + # Seed existing config + self._make_docker_config(docker_dir, {"auths": {}}) + + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir)) + + bak = docker_dir / "config.json.bak" + assert bak.exists() + + +# --------------------------------------------------------------------------- +# DockerInstaller.install — dry_run +# --------------------------------------------------------------------------- + + +class TestDockerInstallerDryRun: + """Tests for DockerInstaller.install with dry_run=True.""" + + def test_no_launcher_written(self, tmp_path, monkeypatch): + """dry_run=True does NOT write a launcher file.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir), dry_run=True) + + assert not (bin_dir / "docker-credential-cloudsmith").exists() + + def test_config_json_not_modified(self, tmp_path, monkeypatch): + """dry_run=True does NOT modify config.json.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir), dry_run=True) + + assert not (docker_dir / "config.json").exists() + + def test_returns_planned_action_strings(self, tmp_path, monkeypatch): + """dry_run=True returns strings describing what would be done.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + installer = DockerInstaller() + actions = installer.install(bin_dir=str(bin_dir), dry_run=True) + + assert any("would write launcher" in a for a in actions) + assert any("docker.cloudsmith.io" in a for a in actions) + + +# --------------------------------------------------------------------------- +# DockerInstaller.install — idempotency +# --------------------------------------------------------------------------- + + +class TestDockerInstallerIdempotent: + """Tests for idempotent second-run behaviour.""" + + def test_second_install_no_change(self, tmp_path, monkeypatch): + """Running install twice does not change config.json the second time.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir)) + + mtime_before = (docker_dir / "config.json").stat().st_mtime + + # Second run — config should be considered up-to-date + actions = installer.install(bin_dir=str(bin_dir)) + + mtime_after = (docker_dir / "config.json").stat().st_mtime + assert mtime_before == mtime_after + assert any("already up to date" in a for a in actions) + + +# --------------------------------------------------------------------------- +# DockerInstaller.uninstall +# --------------------------------------------------------------------------- + + +class TestDockerInstallerUninstall: + """Tests for DockerInstaller.uninstall.""" + + def test_removes_cloudsmith_entries_only(self, tmp_path, monkeypatch): + """uninstall removes cloudsmith entries but leaves foreign helpers.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + # Seed a mixed config + docker_dir.mkdir(parents=True) + cfg_path = docker_dir / "config.json" + cfg_path.write_text( + json.dumps( + { + "credHelpers": { + "docker.cloudsmith.io": "cloudsmith", + "ghcr.io": "gh", + } + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=bin_dir, + ): + installer = DockerInstaller() + installer.uninstall() + + cfg = json.loads(cfg_path.read_text()) + # cloudsmith entry removed + assert "docker.cloudsmith.io" not in cfg.get("credHelpers", {}) + # foreign entry kept + assert cfg["credHelpers"]["ghcr.io"] == "gh" + + def test_removes_launcher(self, tmp_path, monkeypatch): + """uninstall removes the launcher binary if it exists.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + # Write launcher manually + bin_dir.mkdir(parents=True) + launcher = bin_dir / "docker-credential-cloudsmith" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=bin_dir, + ): + installer = DockerInstaller() + installer.uninstall() + + assert not launcher.exists() + + def test_uninstall_dry_run_writes_nothing(self, tmp_path, monkeypatch): + """uninstall with dry_run=True makes no filesystem changes.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + bin_dir.mkdir(parents=True) + launcher = bin_dir / "docker-credential-cloudsmith" + launcher.write_text("#!/bin/sh\n", encoding="utf-8") + + docker_dir.mkdir(parents=True) + cfg_path = docker_dir / "config.json" + data = {"credHelpers": {"docker.cloudsmith.io": "cloudsmith"}} + cfg_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=bin_dir, + ): + installer = DockerInstaller() + actions = installer.uninstall(dry_run=True) + + # Launcher still present, config unchanged + assert launcher.exists() + assert json.loads(cfg_path.read_text()) == data + assert any("would remove" in a for a in actions) + + +# --------------------------------------------------------------------------- +# manage CLI (CliRunner) +# --------------------------------------------------------------------------- + + +class TestManageCLI: + """Tests for the install/uninstall/list Click commands via CliRunner.""" + + def test_install_docker_dry_run_exits_0(self, runner, tmp_path, monkeypatch): + """install docker --dry-run exits 0 and prints a plan.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + ["docker", "--dry-run", "--bin-dir", str(tmp_path / "bin")], + ) + + assert result.exit_code == 0, result.output + assert "would" in result.output.lower() or "dry run" in result.output.lower() + + def test_install_unknown_helper_exits_nonzero(self, runner): + """install with an unknown helper name exits non-zero with an error.""" + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke(install_cmd, ["badhelper"]) + + assert result.exit_code != 0 + + def test_uninstall_unknown_helper_exits_nonzero(self, runner): + """uninstall with an unknown helper name exits non-zero.""" + from ....cli.commands.credential_helper.manage import uninstall_cmd + + result = runner.invoke(uninstall_cmd, ["badhelper"]) + + assert result.exit_code != 0 + + def test_list_exits_0(self, runner, tmp_path, monkeypatch): + """list exits 0 and shows the docker helper entry.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import list_cmd + + result = runner.invoke(list_cmd, []) + + assert result.exit_code == 0, result.output + assert "docker" in result.output + + +# --------------------------------------------------------------------------- +# PATH warning +# --------------------------------------------------------------------------- + + +class TestPathWarning: + """Tests that a WARNING action is emitted when bin_dir is not on PATH.""" + + def test_warning_fires_when_bin_dir_not_on_path(self, tmp_path, monkeypatch): + """install returns a WARNING action when target dir is not on PATH.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + # Keep PATH pointing somewhere else so bin_dir is definitely absent + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") + + installer = DockerInstaller() + actions = installer.install(bin_dir=str(bin_dir)) + + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert warning_actions, f"Expected a WARNING action, got: {actions}" + assert any("PATH" in a for a in warning_actions) + + +# --------------------------------------------------------------------------- +# Unwritable directory → clean ClickException (no raw traceback) +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + os.name != "posix" or (hasattr(os, "geteuid") and os.geteuid() == 0), + reason="permission test only meaningful on POSIX as non-root", +) +class TestUnwritableDirCleanError: + """Tests that an unwritable bin_dir surfaces as a ClickException, not a raw OSError.""" + + def test_unwritable_bin_dir_gives_click_exception( + self, runner, tmp_path, monkeypatch + ): + """install with an unwritable --bin-dir exits non-zero without a bare OSError.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import install_cmd + + ro_dir = tmp_path / "readonly" + ro_dir.mkdir() + ro_dir.chmod(0o500) + + try: + result = runner.invoke( + install_cmd, + ["docker", "--bin-dir", str(ro_dir)], + ) + finally: + # Restore permissions so pytest can clean up tmp_path + ro_dir.chmod(0o700) + + assert result.exit_code != 0 + # The exception path should be a SystemExit (via ClickException), not a + # bare OSError escaping the command. + assert not isinstance( + result.exception, OSError + ), f"Raw OSError escaped: {result.exception}" diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py new file mode 100644 index 00000000..aa15dd21 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -0,0 +1,250 @@ +# Copyright 2026 Cloudsmith Ltd +"""Installer for the Docker credential helper. + +Manages writing/removing the ``docker-credential-cloudsmith`` launcher and +patching ``~/.docker/config.json`` to enable the helper for Cloudsmith +registry hosts. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from ...core.cache_utils import merge_json_file +from ..launchers import is_on_path, remove_launcher, resolve_bin_dir, write_launcher + + +def _docker_config_path() -> Path: + """Return the path to the Docker client configuration file. + + Respects the ``DOCKER_CONFIG`` environment variable; otherwise returns + the platform default ``~/.docker/config.json``. + """ + docker_config_env = os.environ.get("DOCKER_CONFIG") + if docker_config_env: + return Path(docker_config_env) / "config.json" + return Path.home() / ".docker" / "config.json" + + +class DockerInstaller: + """Manages installation of the Docker credential helper for Cloudsmith. + + This installer writes a ``docker-credential-cloudsmith`` launcher binary + and patches ``~/.docker/config.json`` to route the configured registry + hosts through the Cloudsmith credential helper. + + Usage:: + + installer = DockerInstaller() + actions = installer.install(domains=["my-registry.example.com"]) + for action in actions: + print(action) + """ + + LAUNCHER_NAME = "docker-credential-cloudsmith" + TARGET_CMD = "cloudsmith credential-helper docker" + HELPER_VALUE = "cloudsmith" + DEFAULT_HOST = "docker.cloudsmith.io" + + name = "docker" + summary = "Docker credential helper for Cloudsmith registries" + + def install( + self, + *, + bin_dir: str | None = None, + domains: tuple[str, ...] = (), + dry_run: bool = False, + ) -> list[str]: + """Install the Docker credential helper. + + Writes the launcher binary and registers Cloudsmith registry hosts in + ``~/.docker/config.json``. + + Parameters + ---------- + bin_dir: + Override for the directory to install the launcher. Defaults to + :func:`resolve_bin_dir` auto-detection. + domains: + Additional registry hostnames to configure (in addition to the + default ``docker.cloudsmith.io``). + dry_run: + When ``True``, compute and return planned actions without writing + any files. + + Returns + ------- + list[str] + Human-readable descriptions of actions taken (or planned, when + *dry_run* is ``True``). + """ + target_dir = resolve_bin_dir(bin_dir) + config_path = _docker_config_path() + + # De-duplicate while preserving order + seen: set[str] = set() + hosts: list[str] = [] + for h in [self.DEFAULT_HOST, *domains]: + if h not in seen: + seen.add(h) + hosts.append(h) + + def mutate(config: dict) -> None: + config.setdefault("credHelpers", {}) + for host in hosts: + config["credHelpers"][host] = self.HELPER_VALUE + + actions: list[str] = [] + + if dry_run: + if os.name == "nt": + launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd" + else: + launcher_path = target_dir / self.LAUNCHER_NAME + actions.append(f"would write launcher {launcher_path}") + + would_change = merge_json_file(config_path, mutate, dry_run=True) + for host in hosts: + if would_change: + actions.append( + f"would set credHelpers[{host!r}]={self.HELPER_VALUE!r}" + f" in {config_path}" + ) + else: + actions.append( + f"credHelpers[{host!r}] already set" + f" in {config_path} (no change)" + ) + return actions + + # Real install + launcher_path = write_launcher(target_dir, self.LAUNCHER_NAME, self.TARGET_CMD) + actions.append(f"wrote launcher {launcher_path}") + + changed = merge_json_file(config_path, mutate) + if changed: + for host in hosts: + actions.append( + f"set credHelpers[{host!r}]={self.HELPER_VALUE!r}" + f" in {config_path}" + ) + else: + actions.append(f"config.json already up to date ({config_path})") + + if not is_on_path(target_dir): + actions.append( + f"WARNING: {target_dir} is not on PATH — " + "add it to your PATH so Docker can find docker-credential-cloudsmith" + ) + + return actions + + def uninstall(self, *, dry_run: bool = False) -> list[str]: + """Uninstall the Docker credential helper. + + Removes the launcher binary and strips Cloudsmith-managed entries from + ``~/.docker/config.json``. + + Parameters + ---------- + dry_run: + When ``True``, return planned actions without writing any files. + + Returns + ------- + list[str] + Human-readable descriptions of actions taken (or planned). + """ + target_dir = resolve_bin_dir() + config_path = _docker_config_path() + + def mutate(config: dict) -> None: + helpers = config.get("credHelpers", {}) + removed = [k for k, v in helpers.items() if v == self.HELPER_VALUE] + for key in removed: + del helpers[key] + if removed and not config["credHelpers"]: + del config["credHelpers"] + + actions: list[str] = [] + + if os.name == "nt": + launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd" + else: + launcher_path = target_dir / self.LAUNCHER_NAME + + if dry_run: + if launcher_path.exists(): + actions.append(f"would remove launcher {launcher_path}") + else: + actions.append( + f"launcher not found at {launcher_path} (nothing to remove)" + ) + + would_change = merge_json_file(config_path, mutate, dry_run=True) + if would_change: + actions.append( + f"would remove credHelpers entries with value" + f" {self.HELPER_VALUE!r} from {config_path}" + ) + else: + actions.append(f"no credHelpers entries to remove from {config_path}") + return actions + + # Real uninstall + removed = remove_launcher(target_dir, self.LAUNCHER_NAME) + if removed: + actions.append(f"removed launcher {launcher_path}") + else: + actions.append(f"launcher not found at {launcher_path} (nothing to remove)") + + changed = merge_json_file(config_path, mutate) + if changed: + actions.append( + f"removed credHelpers entries with value" + f" {self.HELPER_VALUE!r} from {config_path}" + ) + else: + actions.append(f"no credHelpers entries to remove from {config_path}") + + return actions + + def status(self) -> dict: + """Return current installation status. + + Returns + ------- + dict + A dict with keys: + + ``"launcher"`` + The :class:`~pathlib.Path` of the launcher if it exists, + else ``None``. + ``"hosts"`` + List of hostnames in ``config.json``'s ``credHelpers`` block + whose value equals ``"cloudsmith"``. + """ + target_dir = resolve_bin_dir() + if os.name == "nt": + launcher_path: Path | None = target_dir / f"{self.LAUNCHER_NAME}.cmd" + else: + launcher_path = target_dir / self.LAUNCHER_NAME + + if launcher_path is not None and not launcher_path.exists(): + launcher_path = None + + config_path = _docker_config_path() + hosts: list[str] = [] + if config_path.exists(): + try: + data = json.loads(config_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + helpers = data.get("credHelpers", {}) + hosts = [k for k, v in helpers.items() if v == self.HELPER_VALUE] + except (json.JSONDecodeError, OSError): + pass + + return {"launcher": launcher_path, "hosts": hosts} diff --git a/cloudsmith_cli/credential_helpers/docker/wrapper.py b/cloudsmith_cli/credential_helpers/docker/wrapper.py deleted file mode 100644 index cc115455..00000000 --- a/cloudsmith_cli/credential_helpers/docker/wrapper.py +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env python -""" -Wrapper for docker-credential-cloudsmith. - -This is the entry point binary that Docker calls. It delegates to the main -cloudsmith credential-helper docker command for credential lookups and handles -read-only protocol operations locally. - -See: https://github.com/docker/docker-credential-helpers - -Configure in ~/.docker/config.json: - { - "credHelpers": { - "docker.cloudsmith.io": "cloudsmith" - } - } -""" -import subprocess -import sys - - -def main(): - """ - Docker credential helper wrapper. - - Docker calls this with the operation as argv[1]: - - get: Retrieve credentials - - store: Store credentials (not supported) - - erase: Erase credentials (not supported) - - list: List credentials (not supported) - - The helper is read-only, so only 'get' returns Cloudsmith credentials. - """ - if len(sys.argv) < 2: - print( - "Error: Missing operation argument. " - "Usage: docker-credential-cloudsmith ", - file=sys.stderr, - ) - sys.exit(1) - - operation = sys.argv[1] - - if operation == "get": - try: - result = subprocess.run( - ["cloudsmith", "credential-helper", "docker"], - stdin=sys.stdin, - capture_output=False, - check=False, - ) - sys.exit(result.returncode) - except FileNotFoundError: - print( - "Error: 'cloudsmith' command not found. " - "Make sure cloudsmith-cli is installed.", - file=sys.stderr, - ) - sys.exit(1) - elif operation in ("store", "erase"): - try: - if not sys.stdin.isatty(): - sys.stdin.read() - except (OSError, ValueError): - pass - sys.exit(0) - elif operation == "list": - print("{}") - sys.exit(0) - else: - print( - f"Error: Unknown operation '{operation}'. " - "Valid operations: get, store, erase, list", - file=sys.stderr, - ) - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py new file mode 100644 index 00000000..3c49a007 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -0,0 +1,147 @@ +# Copyright 2026 Cloudsmith Ltd +"""Launcher writer/remover for credential-helper on-PATH binaries. + +Creates a thin shell script (Unix) or .cmd batch file (Windows) named +``docker-credential-cloudsmith`` (or similar) that forwards every call to the +single ``cloudsmith`` binary already installed on the user's PATH. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + + +def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: + """Write a launcher script for *name* in *bin_dir* that execs *target_cmd*. + + Parameters + ---------- + bin_dir: + Directory in which to create the launcher. Created if absent. + name: + Base name of the helper binary (e.g. ``docker-credential-cloudsmith``). + target_cmd: + The command the launcher forwards to (e.g. + ``cloudsmith credential-helper docker``). + + Returns + ------- + Path + The path of the written file. + """ + bin_dir = Path(bin_dir) + bin_dir.mkdir(parents=True, exist_ok=True) + + if os.name == "nt": + dest = Path(os.path.join(str(bin_dir), f"{name}.cmd")) + dest.write_text(f"@echo off\r\n{target_cmd} %*\r\n", encoding="utf-8") + else: + dest = Path(os.path.join(str(bin_dir), name)) + dest.write_text(f'#!/bin/sh\nexec {target_cmd} "$@"\n', encoding="utf-8") + dest.chmod(0o755) + + return dest + + +def remove_launcher(bin_dir: Path, name: str) -> bool: + """Remove a launcher previously created by :func:`write_launcher`. + + Parameters + ---------- + bin_dir: + Directory that contains (or contained) the launcher. + name: + Base name of the helper binary (without extension). + + Returns + ------- + bool + ``True`` if a file was removed, ``False`` if no file was found. + """ + bin_dir = Path(bin_dir) + + if os.name == "nt": + target = Path(os.path.join(str(bin_dir), f"{name}.cmd")) + else: + target = Path(os.path.join(str(bin_dir), name)) + + if target.exists(): + target.unlink() + return True + return False + + +def resolve_bin_dir(override: str | None = None) -> Path: + """Determine the best directory in which to place a launcher. + + Resolution order + ---------------- + 1. *override* → ``Path(override)``. + 2. The directory of the running ``cloudsmith`` executable — if that + directory is writable. + 3. The user-local bin directory: + - Unix: ``~/.local/bin`` + - Windows: ``%LOCALAPPDATA%\\Cloudsmith\\bin`` + + The chosen directory is **not** created here; that happens when the + launcher is written via :func:`write_launcher`. + + Parameters + ---------- + override: + Explicit path supplied by the caller (e.g. ``--bin-dir`` CLI option). + + Returns + ------- + Path + The resolved directory. + """ + if override is not None: + return Path(override) + + # Option 2: beside the running cloudsmith binary (if writable) + cloudsmith_path = shutil.which("cloudsmith") + if cloudsmith_path: + candidate = Path(os.path.dirname(os.path.realpath(cloudsmith_path))) + else: + candidate = Path(os.path.dirname(os.path.realpath(sys.argv[0]))) + + if os.access(candidate, os.W_OK): + return candidate + + # Option 3: user-local bin + if os.name == "nt": + localappdata = os.environ.get("LOCALAPPDATA") + base = Path(localappdata) if localappdata else Path.home() + return Path(os.path.join(str(base), "Cloudsmith", "bin")) + + return Path(os.path.join(str(Path.home()), ".local", "bin")) + + +def is_on_path(directory: Path) -> bool: + """Return True if *directory* is an entry in the current ``$PATH``. + + Comparison is case-insensitive on Windows (``os.path.normcase``) and + normalised via ``os.path.normpath`` on all platforms. + + Parameters + ---------- + directory: + The directory to check. + + Returns + ------- + bool + ``True`` if *directory* appears in ``$PATH``. + """ + needle = os.path.normcase(os.path.normpath(str(directory))) + path_env = os.environ.get("PATH", "") + for entry in path_env.split(os.pathsep): + if not entry: + continue + if os.path.normcase(os.path.normpath(entry)) == needle: + return True + return False diff --git a/setup.py b/setup.py index f98cbf8b..3a2800a0 100644 --- a/setup.py +++ b/setup.py @@ -76,7 +76,6 @@ def get_long_description(): entry_points={ "console_scripts": [ "cloudsmith=cloudsmith_cli.cli.commands.main:main", - "docker-credential-cloudsmith=cloudsmith_cli.credential_helpers.docker.wrapper:main", ] }, keywords=["cloudsmith", "cli", "devops"], From 2c56a1857dc2dda41117d67d727f6ffc4e2e1c47 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 14:51:05 +0100 Subject: [PATCH 14/21] feat(credential-helper): auto-discover Docker custom domains on install Wires custom-domain autodiscovery into `credential-helper install docker`. Discovery is best-effort: if org/credentials are absent, or if the API call fails, the default host is still registered and a clear info/warning action is returned. - custom_domains.py: add `refresh: bool = False` to `get_custom_domains` and `get_format_domains`; when True, skips the cache read and always fetches from the API (writing the fresh result back to cache). - installer.py: extend `DockerInstaller.install` with `discover`, `refresh`, `org`, `api_key`, `auth_type`, `api_host` parameters; adds a single deliberate broad-except discovery boundary with `# pylint: disable=broad-except`. - manage.py: add `--no-discover`, `--refresh`, `--org` (envvar CLOUDSMITH_ORG) options; apply `@common_api_auth_options` + `@resolve_credentials`; thread all new params into `installer.install`. - test_credential_helper_install.py: new test classes covering discovery on/off, missing org/creds skip, failure graceful degradation, dedup, --refresh cache-bypass, and CliRunner smoke tests for the new CLI flags. Co-Authored-By: Claude --- .../cli/commands/credential_helper/manage.py | 53 ++- .../test_credential_helper_install.py | 392 ++++++++++++++++++ .../credential_helpers/custom_domains.py | 9 +- .../credential_helpers/docker/installer.py | 77 +++- 4 files changed, 522 insertions(+), 9 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index d95d851a..8fd42321 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -8,11 +8,13 @@ from __future__ import annotations +import os import sys import click from ....credential_helpers.docker.installer import DockerInstaller +from ...decorators import common_api_auth_options, resolve_credentials # --------------------------------------------------------------------------- # Helper registry — extend here when new helpers are added @@ -74,8 +76,34 @@ def _get_installer(name: str): default=False, help="Show what would be done without making any changes.", ) +@click.option( + "--no-discover", + is_flag=True, + default=False, + help="Disable automatic discovery of custom Docker domains.", +) +@click.option( + "--refresh", + is_flag=True, + default=False, + help="Bypass the custom-domain cache and fetch fresh data from the API.", +) +@click.option( + "--org", + default=None, + help="Cloudsmith organisation slug for custom-domain discovery.", +) +@common_api_auth_options +@resolve_credentials def install_cmd( - helper: str, bin_dir: str | None, domains: tuple[str, ...], dry_run: bool + opts, + helper: str, + bin_dir: str | None, + domains: tuple[str, ...], + dry_run: bool, + no_discover: bool, + refresh: bool, + org: str | None, ) -> None: """Install a credential helper launcher and configure the package manager. @@ -94,10 +122,31 @@ def install_cmd( \b # Preview without making changes $ cloudsmith credential-helper install docker --dry-run + + \b + # Disable automatic custom-domain discovery + $ cloudsmith credential-helper install docker --no-discover """ installer = _get_installer(helper) + org = org or os.environ.get("CLOUDSMITH_ORG", "").strip() or None + api_key = opts.credential.api_key if opts.credential else None + auth_type = ( + getattr(opts.credential, "auth_type", "api_key") + if opts.credential + else "api_key" + ) try: - actions = installer.install(bin_dir=bin_dir, domains=domains, dry_run=dry_run) + actions = installer.install( + bin_dir=bin_dir, + domains=domains, + dry_run=dry_run, + discover=not no_discover, + refresh=refresh, + org=org, + api_key=api_key, + auth_type=auth_type, + api_host=opts.api_host, + ) except OSError as exc: raise click.ClickException( f"Failed to install {helper!r} credential helper: {exc}" diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index f313c7c1..0754fe09 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -581,3 +581,395 @@ def test_unwritable_bin_dir_gives_click_exception( assert not isinstance( result.exception, OSError ), f"Raw OSError escaped: {result.exception}" + + +# --------------------------------------------------------------------------- +# Custom-domain autodiscovery +# --------------------------------------------------------------------------- + +# Import path where installer imports get_format_domains (used for monkeypatching) +_INSTALLER_GET_FORMAT_DOMAINS = ( + "cloudsmith_cli.credential_helpers.docker.installer.get_format_domains" +) + + +class TestDockerInstallerAutodiscovery: + """Tests for DockerInstaller.install custom-domain autodiscovery.""" + + def test_discovery_on_registers_discovered_domains(self, tmp_path, monkeypatch): + """When discover=True and org+api_key present, discovered domains are registered.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + monkeypatch.setattr( + _INSTALLER_GET_FORMAT_DOMAINS, + lambda *_a, **_kw: ["docker.acme.com"], + ) + + installer = DockerInstaller() + actions = installer.install( + bin_dir=str(bin_dir), + discover=True, + org="acme", + api_key="k_test", + ) + + cfg = json.loads((docker_dir / "config.json").read_text()) + assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + assert cfg["credHelpers"]["docker.acme.com"] == "cloudsmith" + assert any("discovered" in a and "1" in a for a in actions) + + def test_no_discover_skips_get_format_domains(self, tmp_path, monkeypatch): + """When discover=False, get_format_domains is never called.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + called = [] + + def _should_not_be_called(*_a, **_kw): + called.append(True) + return [] + + monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called) + + installer = DockerInstaller() + installer.install( + bin_dir=str(bin_dir), + discover=False, + org="acme", + api_key="k_test", + ) + + assert not called, "get_format_domains must not be called when discover=False" + cfg = json.loads((docker_dir / "config.json").read_text()) + assert "docker.cloudsmith.io" in cfg["credHelpers"] + # No extra domain registered + assert "docker.acme.com" not in cfg["credHelpers"] + + def test_missing_org_skips_discovery_install_succeeds(self, tmp_path, monkeypatch): + """discover=True with org=None skips discovery; default host is still registered.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + called = [] + + def _should_not_be_called(*_a, **_kw): + called.append(True) + return [] + + monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called) + + installer = DockerInstaller() + installer.install( + bin_dir=str(bin_dir), + discover=True, + org=None, + api_key="k_test", + ) + + # Discovery must not have run + assert not called, "get_format_domains must not be called when org is absent" + # Default host must still be registered + cfg = json.loads((docker_dir / "config.json").read_text()) + assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + + def test_missing_api_key_skips_discovery_install_succeeds( + self, tmp_path, monkeypatch + ): + """discover=True with api_key=None skips discovery; default host is still registered.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + called = [] + + def _should_not_be_called(*_a, **_kw): + called.append(True) + return [] + + monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called) + + installer = DockerInstaller() + installer.install( + bin_dir=str(bin_dir), + discover=True, + org="acme", + api_key=None, + ) + + # Discovery must not have run + assert ( + not called + ), "get_format_domains must not be called when api_key is absent" + # Default host must still be registered + cfg = json.loads((docker_dir / "config.json").read_text()) + assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + + def test_discovery_failure_is_graceful(self, tmp_path, monkeypatch): + """A discovery error (e.g. network down) must not abort install; returns WARNING.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + def _raise(*_a, **_kw): + raise RuntimeError("network down") + + monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _raise) + + installer = DockerInstaller() + # Must NOT raise + actions = installer.install( + bin_dir=str(bin_dir), + discover=True, + org="acme", + api_key="k_test", + ) + + # Default host still registered + cfg = json.loads((docker_dir / "config.json").read_text()) + assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + + # Launcher created + assert (bin_dir / "docker-credential-cloudsmith").exists() + + # WARNING action present + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert warning_actions, f"Expected a WARNING action, got: {actions}" + assert any("network down" in a for a in warning_actions) + + def test_discovery_returns_default_host_reports_zero_net_new( + self, tmp_path, monkeypatch + ): + """If discovery returns docker.cloudsmith.io (DEFAULT_HOST), credHelpers has + a single entry and the action message reports 0 net-new domains.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + monkeypatch.setattr( + _INSTALLER_GET_FORMAT_DOMAINS, + lambda *_a, **_kw: ["docker.cloudsmith.io"], + ) + + installer = DockerInstaller() + actions = installer.install( + bin_dir=str(bin_dir), + discover=True, + org="acme", + api_key="k_test", + ) + + cfg = json.loads((docker_dir / "config.json").read_text()) + helpers = cfg["credHelpers"] + # Only one entry for the default host + assert list(helpers.keys()).count("docker.cloudsmith.io") == 1 + assert helpers["docker.cloudsmith.io"] == "cloudsmith" + # Discovered action must report 0 net-new + discovered_actions = [a for a in actions if "discovered" in a] + assert discovered_actions, f"Expected a discovered action, got: {actions}" + assert any( + "0" in a for a in discovered_actions + ), f"Expected 0 net-new in discovered action, got: {discovered_actions}" + + def test_dedup_prevents_duplicate_hosts(self, tmp_path, monkeypatch): + """If discovery returns a host already in --domain, it is not duplicated.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + # Both explicit domain and discovered return the same host + monkeypatch.setattr( + _INSTALLER_GET_FORMAT_DOMAINS, + lambda *_a, **_kw: ["docker.acme.com"], + ) + + installer = DockerInstaller() + installer.install( + bin_dir=str(bin_dir), + domains=("docker.acme.com",), + discover=True, + org="acme", + api_key="k_test", + ) + + cfg = json.loads((docker_dir / "config.json").read_text()) + # credHelpers is a dict so duplicates are impossible at the JSON level, + # but we verify the host appears exactly once (dict semantics). + helpers = cfg["credHelpers"] + assert helpers.get("docker.acme.com") == "cloudsmith" + + +# --------------------------------------------------------------------------- +# --refresh bypasses cache (unit test on get_custom_domains) +# --------------------------------------------------------------------------- + + +class TestRefreshBypassesCache: + """Verify that refresh=True skips the on-disk cache in get_custom_domains.""" + + @pytest.fixture(autouse=True) + def _redirect_cache(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + + def test_refresh_false_uses_cache(self, tmp_path): + """refresh=False (default) returns cached domains without hitting the API.""" + import time + + from ....credential_helpers.custom_domains import ( + CustomDomain, + get_cache_path, + get_custom_domains, + write_cache, + ) + + cache_path = get_cache_path("acme") + cached_domain = CustomDomain( + host="docker.acme.com", backend_kind=6, enabled=True, validated=True + ) + write_cache(cache_path, [cached_domain]) + # Touch the mtime to make the cache look fresh + os.utime(cache_path, (time.time(), time.time())) + + api_called = [] + + def _boom(*_a, **_kw): + api_called.append(True) + raise AssertionError("API should not be called when cache is valid") + + with patch( + "cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains", + _boom, + ): + result = get_custom_domains("acme", api_key="k", refresh=False) + + assert not api_called + assert result == [cached_domain] + + def test_refresh_true_bypasses_cache(self, tmp_path): + """refresh=True fetches from the API even when a valid cache exists.""" + import time + + from ....credential_helpers.custom_domains import ( + CustomDomain, + get_cache_path, + get_custom_domains, + write_cache, + ) + + cache_path = get_cache_path("acme") + stale_domain = CustomDomain( + host="old.acme.com", backend_kind=6, enabled=True, validated=True + ) + write_cache(cache_path, [stale_domain]) + os.utime(cache_path, (time.time(), time.time())) + + fresh_domain = CustomDomain( + host="new.acme.com", backend_kind=6, enabled=True, validated=True + ) + + def _fake_list(*_a, **_kw): + return [ + { + "host": "new.acme.com", + "backend_kind": 6, + "enabled": True, + "validated": True, + } + ] + + with patch( + "cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains", + _fake_list, + ): + result = get_custom_domains("acme", api_key="k", refresh=True) + + assert result == [fresh_domain] + + +# --------------------------------------------------------------------------- +# manage CLI — new flags +# --------------------------------------------------------------------------- + + +class TestManageCLINewFlags: + """Tests for --no-discover, --refresh, and --org on the install CLI command.""" + + def test_install_no_discover_dry_run_exits_0(self, runner, tmp_path, monkeypatch): + """install docker --no-discover --dry-run exits 0.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + [ + "docker", + "--no-discover", + "--dry-run", + "--bin-dir", + str(tmp_path / "bin"), + ], + ) + + assert result.exit_code == 0, result.output + assert "would" in result.output.lower() or "dry run" in result.output.lower() + + def test_install_dry_run_with_stubbed_discovery_exits_0( + self, runner, tmp_path, monkeypatch + ): + """install docker --dry-run with get_format_domains stubbed exits 0.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + # Stub discovery so no real network call is made even if org+key are present + monkeypatch.setattr( + _INSTALLER_GET_FORMAT_DOMAINS, + lambda *_a, **_kw: [], + ) + + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + ["docker", "--dry-run", "--bin-dir", str(tmp_path / "bin")], + ) + + assert result.exit_code == 0, result.output + + def test_install_no_discover_does_not_call_get_format_domains( + self, runner, tmp_path, monkeypatch + ): + """--no-discover prevents get_format_domains from being called.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + + called = [] + + def _should_not_be_called(*_a, **_kw): + called.append(True) + return [] + + monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called) + + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + ["docker", "--no-discover", "--bin-dir", str(bin_dir)], + ) + + assert result.exit_code == 0, result.output + assert not called, "get_format_domains must not be called with --no-discover" diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py index 582a8d01..e854b2d0 100644 --- a/cloudsmith_cli/credential_helpers/custom_domains.py +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -157,6 +157,7 @@ def get_custom_domains( # pylint: disable=too-many-return-statements api_key: str | None = None, auth_type: str = "api_key", api_host: str | None = None, + refresh: bool = False, ) -> list[CustomDomain]: """ Fetch custom domains for a Cloudsmith organization. @@ -169,6 +170,8 @@ def get_custom_domains( # pylint: disable=too-many-return-statements auth_type: "api_key" (uses X-Api-Key header) or "bearer" (uses Authorization: Bearer) api_host: Cloudsmith API host URL (including version). Taken from the SDK configuration default when not provided. + refresh: When ``True``, skip the cache read and always fetch from the API. + The fresh result is still written to the cache. Returns: List of CustomDomain records. @@ -182,7 +185,7 @@ def get_custom_domains( # pylint: disable=too-many-return-statements so the library stays free of bare ``except Exception`` (reviewer feedback). """ cache_path = get_cache_path(org) - cached = read_cache(cache_path) + cached = None if refresh else read_cache(cache_path) if cached is not None: logger.debug("Using cached custom domains for %s", org) return cached @@ -252,6 +255,7 @@ def get_format_domains( api_key: str | None = None, auth_type: str = "api_key", api_host: str | None = None, + refresh: bool = False, ) -> list[str]: """ Return enabled and validated custom domain hostnames for a specific backend format. @@ -262,12 +266,13 @@ def get_format_domains( api_key: Optional API key/token for authentication auth_type: "api_key" or "bearer" api_host: Cloudsmith API host URL + refresh: When ``True``, bypass the cache and fetch fresh data from the API. Returns: List of hostnames that are enabled, validated, and match the given backend_kind. """ domains = get_custom_domains( - org, api_key=api_key, auth_type=auth_type, api_host=api_host + org, api_key=api_key, auth_type=auth_type, api_host=api_host, refresh=refresh ) return [ d.host diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index aa15dd21..4941716e 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -9,12 +9,17 @@ from __future__ import annotations import json +import logging import os from pathlib import Path from ...core.cache_utils import merge_json_file +from ..backends import BackendKind +from ..custom_domains import get_format_domains from ..launchers import is_on_path, remove_launcher, resolve_bin_dir, write_launcher +logger = logging.getLogger(__name__) + def _docker_config_path() -> Path: """Return the path to the Docker client configuration file. @@ -56,6 +61,12 @@ def install( *, bin_dir: str | None = None, domains: tuple[str, ...] = (), + discover: bool = True, + refresh: bool = False, + org: str | None = None, + api_key: str | None = None, + auth_type: str = "api_key", + api_host: str | None = None, dry_run: bool = False, ) -> list[str]: """Install the Docker credential helper. @@ -71,6 +82,21 @@ def install( domains: Additional registry hostnames to configure (in addition to the default ``docker.cloudsmith.io``). + discover: + When ``True`` (default), attempt to auto-discover Docker custom + domains via the Cloudsmith API. Discovery is best-effort and never + prevents the defaults from being registered. + refresh: + When ``True``, bypass the domain cache and fetch fresh data from + the API. Only meaningful when *discover* is also ``True``. + org: + Cloudsmith organisation slug used for custom-domain discovery. + api_key: + API key used for custom-domain discovery. + auth_type: + Credential type: ``"api_key"`` (default) or ``"bearer"``. + api_host: + Cloudsmith API host URL override. dry_run: When ``True``, compute and return planned actions without writing any files. @@ -84,21 +110,62 @@ def install( target_dir = resolve_bin_dir(bin_dir) config_path = _docker_config_path() + actions: list[str] = [] + + # Start with the default host plus any explicitly requested domains. + hosts: list[str] = [self.DEFAULT_HOST, *domains] + + # --- Custom-domain auto-discovery (best-effort) --- + if discover: + if org and api_key: + # Discovery boundary: network/SDK errors must never abort the + # default install. ApiException is already handled inside + # get_format_domains; this broad catch is the deliberate outer + # boundary (consistent with "boundary catches, library stays clean"). + # Note: BaseException subclasses (KeyboardInterrupt/SystemExit) + # intentionally propagate — they are not caught by `except Exception`. + try: + discovered = get_format_domains( + org, + BackendKind.DOCKER, + api_key=api_key, + auth_type=auth_type, + api_host=api_host, + refresh=refresh, + ) + except Exception as exc: # pylint: disable=broad-except + # Discovery is best-effort: never let it abort the install of + # the defaults. (Network/SDK errors degrade to a warning; + # ApiException is already handled inside.) + actions.append( + f"WARNING: custom-domain auto-discovery failed: {exc}" + ) + discovered = [] + new_hosts = [h for h in discovered if h not in hosts] + hosts.extend(discovered) + actions.append( + f"discovered {len(new_hosts)} new Docker custom domain(s)" + ) + else: + logger.debug( + "skipped auto-discovery" + " (no organization/credentials; pass --no-discover to silence)" + ) + # De-duplicate while preserving order seen: set[str] = set() - hosts: list[str] = [] - for h in [self.DEFAULT_HOST, *domains]: + deduped: list[str] = [] + for h in hosts: if h not in seen: seen.add(h) - hosts.append(h) + deduped.append(h) + hosts = deduped def mutate(config: dict) -> None: config.setdefault("credHelpers", {}) for host in hosts: config["credHelpers"][host] = self.HELPER_VALUE - actions: list[str] = [] - if dry_run: if os.name == "nt": launcher_path = target_dir / f"{self.LAUNCHER_NAME}.cmd" From 22f3e2b78ccbe9240a9a8fca22e54cabb72ffa32 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 15:11:27 +0100 Subject: [PATCH 15/21] fix(credential-helper): honor --bin-dir on uninstall Add a `bin_dir` keyword param to `DockerInstaller.uninstall` and wire it through to `resolve_bin_dir`, so that `uninstall docker --bin-dir /custom` locates and removes the launcher written by `install --bin-dir /custom` instead of silently looking in the auto-resolved directory. Thread the new `--bin-dir` Click option into `uninstall_cmd` in manage.py (matching the help-text style of the install counterpart). Two on-disk tests verify the fix: one confirms the launcher is gone after a matching uninstall, the other confirms a mismatched dir leaves the launcher intact. Co-Authored-By: Claude --- .../cli/commands/credential_helper/manage.py | 7 +++- .../test_credential_helper_install.py | 41 ++++++++++++++----- .../credential_helpers/docker/installer.py | 11 ++++- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 8fd42321..6957210a 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -168,13 +168,16 @@ def install_cmd( @click.command("uninstall") @click.argument("helper") +@click.option( + "--bin-dir", default=None, help="Directory where the launcher binary was installed." +) @click.option( "--dry-run", is_flag=True, default=False, help="Show what would be done without making any changes.", ) -def uninstall_cmd(helper: str, dry_run: bool) -> None: +def uninstall_cmd(helper: str, bin_dir: str | None, dry_run: bool) -> None: """Uninstall a credential helper launcher and remove its config entries. HELPER is the name of the credential helper to uninstall (e.g. ``docker``). @@ -191,7 +194,7 @@ def uninstall_cmd(helper: str, dry_run: bool) -> None: """ installer = _get_installer(helper) try: - actions = installer.uninstall(dry_run=dry_run) + actions = installer.uninstall(bin_dir=bin_dir, dry_run=dry_run) except OSError as exc: raise click.ClickException( f"Failed to uninstall {helper!r} credential helper: {exc}" diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index 0754fe09..ea648ac6 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -192,11 +192,6 @@ def test_falls_back_to_windows_user_bin(self, tmp_path, monkeypatch): assert result_str == expected_str -# --------------------------------------------------------------------------- -# is_on_path -# --------------------------------------------------------------------------- - - class TestIsOnPath: """Tests for is_on_path.""" @@ -349,11 +344,6 @@ def test_returns_planned_action_strings(self, tmp_path, monkeypatch): assert any("docker.cloudsmith.io" in a for a in actions) -# --------------------------------------------------------------------------- -# DockerInstaller.install — idempotency -# --------------------------------------------------------------------------- - - class TestDockerInstallerIdempotent: """Tests for idempotent second-run behaviour.""" @@ -466,6 +456,37 @@ def test_uninstall_dry_run_writes_nothing(self, tmp_path, monkeypatch): assert json.loads(cfg_path.read_text()) == data assert any("would remove" in a for a in actions) + def test_custom_bin_dir_launcher_is_removed(self, tmp_path, monkeypatch): + """uninstall(bin_dir=) removes the launcher installed there.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + custom_bin_dir = tmp_path / "custom_bin" + installer = DockerInstaller() + installer.install(bin_dir=str(custom_bin_dir)) + launcher = custom_bin_dir / "docker-credential-cloudsmith" + assert launcher.exists(), "Precondition: launcher must exist after install" + installer.uninstall(bin_dir=str(custom_bin_dir)) + assert not launcher.exists(), "Launcher must be removed after uninstall" + + def test_uninstall_wrong_bin_dir_leaves_launcher_intact( + self, tmp_path, monkeypatch + ): + """uninstall(bin_dir=) reports nothing-to-remove; launcher in original dir stays.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + custom_bin_dir = tmp_path / "custom_bin" + installer = DockerInstaller() + installer.install(bin_dir=str(custom_bin_dir)) + launcher = custom_bin_dir / "docker-credential-cloudsmith" + assert launcher.exists(), "Precondition: launcher must exist after install" + other_dir = tmp_path / "other_bin" + other_dir.mkdir(parents=True) + actions = installer.uninstall(bin_dir=str(other_dir)) + assert launcher.exists(), "Launcher in custom_bin must remain untouched" + assert any( + "nothing to remove" in a for a in actions + ), f"Expected 'nothing to remove' in actions, got: {actions}" + # --------------------------------------------------------------------------- # manage CLI (CliRunner) diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index 4941716e..e4f9defa 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -209,7 +209,9 @@ def mutate(config: dict) -> None: return actions - def uninstall(self, *, dry_run: bool = False) -> list[str]: + def uninstall( + self, *, bin_dir: str | None = None, dry_run: bool = False + ) -> list[str]: """Uninstall the Docker credential helper. Removes the launcher binary and strips Cloudsmith-managed entries from @@ -217,6 +219,11 @@ def uninstall(self, *, dry_run: bool = False) -> list[str]: Parameters ---------- + bin_dir: + Override for the directory where the launcher was installed. + Defaults to :func:`resolve_bin_dir` auto-detection. Pass the same + value that was given to :meth:`install` so the correct launcher file + is found and removed. dry_run: When ``True``, return planned actions without writing any files. @@ -225,7 +232,7 @@ def uninstall(self, *, dry_run: bool = False) -> list[str]: list[str] Human-readable descriptions of actions taken (or planned). """ - target_dir = resolve_bin_dir() + target_dir = resolve_bin_dir(bin_dir) config_path = _docker_config_path() def mutate(config: dict) -> None: From 7c9891c1f96416887a0937c781986fe91886ef0a Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 15:34:59 +0100 Subject: [PATCH 16/21] feat(credential-helper): support -F/--output-format json for install/uninstall/list Add -F/--output-format {pretty,json,pretty_json} to the three credential-helper management commands (install, uninstall, list) following the repo convention. JSON mode emits {"data": ...} on stdout with no human text; pretty mode is unchanged. The runtime docker command is untouched. Co-Authored-By: Claude --- .../cli/commands/credential_helper/manage.py | 100 +++-- .../test_credential_helper_install.py | 360 ++++++++++++++++++ .../credential_helpers/docker/installer.py | 5 +- 3 files changed, 442 insertions(+), 23 deletions(-) diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 6957210a..54d3a3ae 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -14,7 +14,13 @@ import click from ....credential_helpers.docker.installer import DockerInstaller -from ...decorators import common_api_auth_options, resolve_credentials +from ... import utils +from ...decorators import ( + common_api_auth_options, + common_cli_config_options, + common_cli_output_options, + resolve_credentials, +) # --------------------------------------------------------------------------- # Helper registry — extend here when new helpers are added @@ -93,9 +99,13 @@ def _get_installer(name: str): default=None, help="Cloudsmith organisation slug for custom-domain discovery.", ) +@common_cli_config_options +@common_cli_output_options @common_api_auth_options @resolve_credentials +@click.pass_context def install_cmd( + ctx, opts, helper: str, bin_dir: str | None, @@ -152,13 +162,24 @@ def install_cmd( f"Failed to install {helper!r} credential helper: {exc}" ) + use_stderr = utils.should_use_stderr(opts) + warnings = [a for a in actions if a.startswith("WARNING")] + normal = [a for a in actions if not a.startswith("WARNING")] + data = { + "helper": helper, + "dry_run": dry_run, + "actions": normal, + "warnings": warnings, + } + if utils.maybe_print_as_json(opts, data): + return + if dry_run: - click.echo("Dry run — no changes will be made:") - for action in actions: - if action.startswith("WARNING"): - click.secho(f" {action}" if dry_run else action, err=True, fg="yellow") - else: - click.echo(f" {action}" if dry_run else action) + click.echo("Dry run — no changes will be made:", err=use_stderr) + for action in normal: + click.echo(f" {action}" if dry_run else action, err=use_stderr) + for warning in warnings: + click.secho(f" {warning}" if dry_run else warning, err=True, fg="yellow") # --------------------------------------------------------------------------- @@ -177,7 +198,10 @@ def install_cmd( default=False, help="Show what would be done without making any changes.", ) -def uninstall_cmd(helper: str, bin_dir: str | None, dry_run: bool) -> None: +@common_cli_config_options +@common_cli_output_options +@click.pass_context +def uninstall_cmd(ctx, opts, helper: str, bin_dir: str | None, dry_run: bool) -> None: """Uninstall a credential helper launcher and remove its config entries. HELPER is the name of the credential helper to uninstall (e.g. ``docker``). @@ -200,13 +224,24 @@ def uninstall_cmd(helper: str, bin_dir: str | None, dry_run: bool) -> None: f"Failed to uninstall {helper!r} credential helper: {exc}" ) + use_stderr = utils.should_use_stderr(opts) + warnings = [a for a in actions if a.startswith("WARNING")] + normal = [a for a in actions if not a.startswith("WARNING")] + data = { + "helper": helper, + "dry_run": dry_run, + "actions": normal, + "warnings": warnings, + } + if utils.maybe_print_as_json(opts, data): + return + if dry_run: - click.echo("Dry run — no changes will be made:") - for action in actions: - if action.startswith("WARNING"): - click.secho(f" {action}" if dry_run else action, err=True, fg="yellow") - else: - click.echo(f" {action}" if dry_run else action) + click.echo("Dry run — no changes will be made:", err=use_stderr) + for action in normal: + click.echo(f" {action}" if dry_run else action, err=use_stderr) + for warning in warnings: + click.secho(f" {warning}" if dry_run else warning, err=True, fg="yellow") # --------------------------------------------------------------------------- @@ -215,7 +250,10 @@ def uninstall_cmd(helper: str, bin_dir: str | None, dry_run: bool) -> None: @click.command("list") -def list_cmd() -> None: +@common_cli_config_options +@common_cli_output_options +@click.pass_context +def list_cmd(ctx, opts) -> None: """List available credential helpers and their installation status. Shows which helpers are available, whether their launcher binary is @@ -226,18 +264,36 @@ def list_cmd() -> None: \b $ cloudsmith credential-helper list """ + use_stderr = utils.should_use_stderr(opts) + + data = [] for name, cls in sorted(_INSTALLERS.items()): installer = cls() st = installer.status() - launcher = st.get("launcher") - hosts = st.get("hosts", []) + data.append( + { + "helper": name, + "summary": installer.summary, + "launcher": st.get("launcher"), + "hosts": st.get("hosts", []), + } + ) + + if utils.maybe_print_as_json(opts, data): + return + + for entry in data: + name = entry["helper"] + launcher = entry["launcher"] + hosts = entry["hosts"] + summary = entry["summary"] - click.echo(f"{name} ({installer.summary})") + click.echo(f"{name} ({summary})", err=use_stderr) if launcher: - click.echo(f" launcher : {launcher}") + click.echo(f" launcher : {launcher}", err=use_stderr) else: - click.echo(" launcher : not installed") + click.echo(" launcher : not installed", err=use_stderr) if hosts: - click.echo(f" hosts : {', '.join(hosts)}") + click.echo(f" hosts : {', '.join(hosts)}", err=use_stderr) else: - click.echo(" hosts : none configured") + click.echo(" hosts : none configured", err=use_stderr) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index ea648ac6..46e59df6 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -1,4 +1,5 @@ # Copyright 2026 Cloudsmith Ltd +# pylint: disable=too-many-lines """Tests for credential-helper install/uninstall/list commands and launchers.""" from __future__ import annotations @@ -488,6 +489,79 @@ def test_uninstall_wrong_bin_dir_leaves_launcher_intact( ), f"Expected 'nothing to remove' in actions, got: {actions}" +# --------------------------------------------------------------------------- +# DockerInstaller.status() return-type contract +# --------------------------------------------------------------------------- + + +class TestDockerInstallerStatusReturnType: + """Unit assertions on the type contract of DockerInstaller.status(). + + Fix 3: status()["launcher"] must be str or None — never a pathlib.Path — + so that `list -F json` can serialise the value without error. + """ + + def test_launcher_is_none_when_not_installed(self, tmp_path, monkeypatch): + """When no launcher file exists, status()["launcher"] is None.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=tmp_path / "bin", + ): + installer = DockerInstaller() + result = installer.status() + + assert result["launcher"] is None + + def test_launcher_is_str_when_installed(self, tmp_path, monkeypatch): + """When a launcher file exists, status()["launcher"] is a str, not a Path.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + # Write a real launcher so status() finds it + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir)) + + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=bin_dir, + ): + result = installer.status() + + launcher = result["launcher"] + assert launcher is not None, "Expected a launcher path after install" + assert isinstance( + launcher, str + ), f"status()['launcher'] must be str, got {type(launcher).__name__!r}" + # Sanity-check the path points to the real launcher file + assert launcher.endswith("docker-credential-cloudsmith") + + def test_launcher_never_a_path_object(self, tmp_path, monkeypatch): + """status()["launcher"] is never a pathlib.Path instance regardless of install state.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + installer = DockerInstaller() + # Check before install + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=bin_dir, + ): + before = installer.status() + assert not isinstance(before["launcher"], Path) + + # Install, then check again + installer.install(bin_dir=str(bin_dir)) + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=bin_dir, + ): + after = installer.status() + assert not isinstance(after["launcher"], Path) + + # --------------------------------------------------------------------------- # manage CLI (CliRunner) # --------------------------------------------------------------------------- @@ -994,3 +1068,289 @@ def _should_not_be_called(*_a, **_kw): assert result.exit_code == 0, result.output assert not called, "get_format_domains must not be called with --no-discover" + + +# --------------------------------------------------------------------------- +# -F / --output-format tests +# --------------------------------------------------------------------------- + + +class TestOutputFormat: + """Tests for -F json / -F pretty_json on install, uninstall, and list.""" + + # ------------------------------------------------------------------ + # install + # ------------------------------------------------------------------ + + def test_install_json_exits_0_and_parses(self, runner, tmp_path, monkeypatch): + """-F json: install exits 0 and stdout is valid JSON with expected shape.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + [ + "docker", + "--dry-run", + "--no-discover", + "--bin-dir", + str(tmp_path / "bin"), + "-F", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + data = parsed["data"] + assert data["helper"] == "docker" + assert data["dry_run"] is True + assert isinstance(data["actions"], list) + assert isinstance(data["warnings"], list) + + def test_install_pretty_json_exits_0_and_parses( + self, runner, tmp_path, monkeypatch + ): + """-F pretty_json: install exits 0 and stdout is valid JSON.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + [ + "docker", + "--dry-run", + "--no-discover", + "--bin-dir", + str(tmp_path / "bin"), + "-F", + "pretty_json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + assert "data" in parsed + + def test_install_default_pretty_shows_human_text( + self, runner, tmp_path, monkeypatch + ): + """Default (no -F): install exits 0 and human-readable text appears.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + [ + "docker", + "--dry-run", + "--no-discover", + "--bin-dir", + str(tmp_path / "bin"), + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert "dry run" in result.output.lower() or "would" in result.output.lower() + + def test_install_json_stdout_is_pure_json(self, runner, tmp_path, monkeypatch): + """-F json: stdout contains no leading human text before the JSON.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import install_cmd + + result = runner.invoke( + install_cmd, + [ + "docker", + "--dry-run", + "--no-discover", + "--bin-dir", + str(tmp_path / "bin"), + "-F", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + # Must parse cleanly from position 0 — no leading prose + json.loads(result.output) + + # ------------------------------------------------------------------ + # uninstall + # ------------------------------------------------------------------ + + def test_uninstall_json_exits_0_and_parses(self, runner, tmp_path, monkeypatch): + """-F json: uninstall exits 0 and stdout is valid JSON with expected shape.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import uninstall_cmd + + result = runner.invoke( + uninstall_cmd, + [ + "docker", + "--dry-run", + "-F", + "json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + data = parsed["data"] + assert data["helper"] == "docker" + assert data["dry_run"] is True + assert isinstance(data["actions"], list) + assert isinstance(data["warnings"], list) + + def test_uninstall_pretty_json_exits_0_and_parses( + self, runner, tmp_path, monkeypatch + ): + """-F pretty_json: uninstall exits 0 and stdout is valid JSON.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import uninstall_cmd + + result = runner.invoke( + uninstall_cmd, + [ + "docker", + "--dry-run", + "-F", + "pretty_json", + ], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + assert "data" in parsed + + def test_uninstall_default_pretty_shows_human_text( + self, runner, tmp_path, monkeypatch + ): + """Default (no -F): uninstall exits 0 and human-readable text appears.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + + from ....cli.commands.credential_helper.manage import uninstall_cmd + + result = runner.invoke( + uninstall_cmd, + ["docker", "--dry-run"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + # dry-run with nothing installed produces output like "nothing to remove" + assert len(result.output) > 0 + + # ------------------------------------------------------------------ + # list + # ------------------------------------------------------------------ + + # Deterministic status fixture used by all list tests — includes a launcher + # path so that the "launcher present" JSON-serialisation path is exercised. + _STUB_STATUS = { + "launcher": "/some/bin/docker-credential-cloudsmith", + "hosts": ["docker.cloudsmith.io"], + } + + # Deterministic status return value used by all list tests — includes a + # launcher path so the "launcher present" JSON-serialisation path is covered. + _STUB_STATUS = { + "launcher": "/some/bin/docker-credential-cloudsmith", + "hosts": ["docker.cloudsmith.io"], + } + + @staticmethod + def _stub_status(_self): + return { + "launcher": "/some/bin/docker-credential-cloudsmith", + "hosts": ["docker.cloudsmith.io"], + } + + def test_list_json_exits_0_and_parses(self, runner, tmp_path, monkeypatch): + """-F json: list exits 0 and stdout is valid JSON with expected shape. + + DockerInstaller.status is patched to return a deterministic dict WITH a + launcher path present so this test covers the previously-failing + serialisation of pathlib.Path values. + """ + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setattr(DockerInstaller, "status", self._stub_status) + + from ....cli.commands.credential_helper.manage import list_cmd + + result = runner.invoke(list_cmd, ["-F", "json"], catch_exceptions=False) + + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + data = parsed["data"] + assert isinstance(data, list) + assert len(data) >= 1 + docker_entry = next(e for e in data if e["helper"] == "docker") + assert "launcher" in docker_entry + assert docker_entry["launcher"] == "/some/bin/docker-credential-cloudsmith" + assert "hosts" in docker_entry + assert docker_entry["hosts"] == ["docker.cloudsmith.io"] + assert isinstance(docker_entry["hosts"], list) + + def test_list_pretty_json_exits_0_and_parses(self, runner, tmp_path, monkeypatch): + """-F pretty_json: list exits 0 and stdout is valid JSON. + + Launcher present in patched status ensures Path serialisation is covered. + """ + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setattr(DockerInstaller, "status", self._stub_status) + + from ....cli.commands.credential_helper.manage import list_cmd + + result = runner.invoke(list_cmd, ["-F", "pretty_json"], catch_exceptions=False) + + assert result.exit_code == 0, result.output + parsed = json.loads(result.output) + assert "data" in parsed + + def test_list_default_pretty_shows_docker(self, runner, tmp_path, monkeypatch): + """Default (no -F): list exits 0 and docker entry with launcher path appears in output.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setattr(DockerInstaller, "status", self._stub_status) + + from ....cli.commands.credential_helper.manage import list_cmd + + result = runner.invoke(list_cmd, [], catch_exceptions=False) + + assert result.exit_code == 0, result.output + assert "docker" in result.output + assert "/some/bin/docker-credential-cloudsmith" in result.output + + def test_list_json_stdout_is_pure_json(self, runner, tmp_path, monkeypatch): + """-F json: stdout starts with '{' — no human text leaks before JSON. + + Regression guard: previously failed with "Failed to convert to JSON: + Type not serializable" when a launcher was + present. Patching status with a launcher-present dict reproduces that + environment deterministically. + """ + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setattr(DockerInstaller, "status", self._stub_status) + + from ....cli.commands.credential_helper.manage import list_cmd + + result = runner.invoke(list_cmd, ["-F", "json"], catch_exceptions=False) + + assert result.exit_code == 0, result.output + # Strip trailing whitespace only; the first non-whitespace char must open JSON + assert result.output.strip().startswith("{") + data = json.loads(result.output) + docker_entry = next(e for e in data["data"] if e["helper"] == "docker") + assert docker_entry["launcher"] == "/some/bin/docker-credential-cloudsmith" diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index e4f9defa..e1df0f51 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -321,4 +321,7 @@ def status(self) -> dict: except (json.JSONDecodeError, OSError): pass - return {"launcher": launcher_path, "hosts": hosts} + return { + "launcher": str(launcher_path) if launcher_path is not None else None, + "hosts": hosts, + } From 75326be49bc8bd386473bf56b28be619501b4c34 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 16:13:59 +0100 Subject: [PATCH 17/21] feat(credential-helper): scope docker runtime to docker-format custom domains Pass BackendKind.DOCKER to is_cloudsmith_domain so the Docker credential helper only vouches for Docker-format custom domains. Adds backend_kind optional param to is_cloudsmith_domain (None default preserves existing generic behavior); updates docker/runtime.py to pass BackendKind.DOCKER; extends TestIsCloudsmithDomain and adds TestDockerRuntimeBackendKindFiltering to cover filtering correctness. Co-Authored-By: Claude --- .../tests/commands/test_credential_helper.py | 169 ++++++++++++++++++ cloudsmith_cli/credential_helpers/common.py | 37 ++-- .../credential_helpers/docker/runtime.py | 2 + 3 files changed, 198 insertions(+), 10 deletions(-) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index 1ecec75a..cb419ab9 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -736,3 +736,172 @@ def test_custom_enabled_not_validated_host_false(self, tmp_path, monkeypatch): ) is False ) + + # ------------------------------------------------------------------ + # backend_kind filtering + # ------------------------------------------------------------------ + + def test_backend_kind_docker_matches_docker_custom_domain( + self, tmp_path, monkeypatch + ): + """With backend_kind=DOCKER, a Docker custom domain (kind=6) returns True.""" + from ....credential_helpers.common import is_cloudsmith_domain + + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="docker.acme.com", + backend_kind=6, + enabled=True, + validated=True, + ) + ], + ) + + assert ( + is_cloudsmith_domain( + "docker.acme.com", + api_key="k_abc", + api_host=API_HOST, + backend_kind=BackendKind.DOCKER, + ) + is True + ) + + def test_backend_kind_docker_rejects_npm_custom_domain(self, tmp_path, monkeypatch): + """With backend_kind=DOCKER, an NPM custom domain (kind=9) returns False.""" + from ....credential_helpers.common import is_cloudsmith_domain + + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="npm.acme.com", + backend_kind=9, + enabled=True, + validated=True, + ) + ], + ) + + assert ( + is_cloudsmith_domain( + "npm.acme.com", + api_key="k_abc", + api_host=API_HOST, + backend_kind=BackendKind.DOCKER, + ) + is False + ) + + def test_backend_kind_docker_standard_domain_always_true(self): + """Standard *.cloudsmith.io is True even when backend_kind=DOCKER (no API call).""" + from ....credential_helpers.common import is_cloudsmith_domain + + assert ( + is_cloudsmith_domain( + "docker.cloudsmith.io", + backend_kind=BackendKind.DOCKER, + ) + is True + ) + assert ( + is_cloudsmith_domain( + "something.cloudsmith.io", + backend_kind=BackendKind.DOCKER, + ) + is True + ) + + def test_backend_kind_none_default_matches_any_format(self, tmp_path, monkeypatch): + """backend_kind=None (default) accepts any enabled+validated custom domain.""" + from ....credential_helpers.common import is_cloudsmith_domain + + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="npm.acme.com", + backend_kind=9, + enabled=True, + validated=True, + ) + ], + ) + + # Default (no backend_kind) still returns True for any format + assert ( + is_cloudsmith_domain( + "npm.acme.com", + api_key="k_abc", + api_host=API_HOST, + ) + is True + ) + + +class TestDockerRuntimeBackendKindFiltering: + """Tests that the Docker runtime refuses non-Docker custom domains.""" + + @pytest.fixture(autouse=True) + def _cache_dir(self, tmp_path, monkeypatch): + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + + def test_get_credentials_refuses_npm_custom_domain(self, tmp_path): + """get_credentials returns None for an NPM custom domain (not a Docker registry).""" + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="npm.acme.com", + backend_kind=9, + enabled=True, + validated=True, + ) + ], + ) + + credential = CredentialResult(api_key="k_xyz", source_name="test") + result = helper_get_credentials( + "npm.acme.com", + credential=credential, + api_host=API_HOST, + ) + + assert result is None + + def test_get_credentials_serves_docker_custom_domain(self, tmp_path): + """get_credentials returns creds for a Docker custom domain (backend_kind=6).""" + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="docker.acme.com", + backend_kind=6, + enabled=True, + validated=True, + ) + ], + ) + + credential = CredentialResult(api_key="k_xyz", source_name="test") + result = helper_get_credentials( + "docker.acme.com", + credential=credential, + api_host=API_HOST, + ) + + assert result == {"Username": "token", "Secret": "k_xyz"} diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index 341b0d88..9fb207eb 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -7,7 +7,7 @@ import logging import os -from .custom_domains import get_custom_domains +from .custom_domains import get_custom_domains, get_format_domains logger = logging.getLogger(__name__) @@ -47,7 +47,9 @@ def extract_hostname(url): return hostname -def is_cloudsmith_domain(url, api_key=None, auth_type="api_key", api_host=None): +def is_cloudsmith_domain( + url, api_key=None, auth_type="api_key", api_host=None, backend_kind=None +): """ Check if a URL points to a Cloudsmith service. @@ -59,6 +61,9 @@ def is_cloudsmith_domain(url, api_key=None, auth_type="api_key", api_host=None): api_key: API key/token for authenticating custom domain lookups auth_type: "api_key" (X-Api-Key header) or "bearer" (Authorization: Bearer) api_host: Cloudsmith API host URL + backend_kind: If given, custom domains only match when their backend_kind + equals it (standard *.cloudsmith.io domains always match regardless). + When None (default), any enabled+validated custom domain matches. Returns: bool: True if this is a Cloudsmith domain @@ -67,7 +72,7 @@ def is_cloudsmith_domain(url, api_key=None, auth_type="api_key", api_host=None): if not hostname: return False - # Standard Cloudsmith domains — no auth needed + # Standard Cloudsmith domains — no auth needed, always match regardless of backend_kind if ( hostname in ("cloudsmith.io", "cloudsmith.com") or hostname.endswith(".cloudsmith.io") @@ -83,11 +88,23 @@ def is_cloudsmith_domain(url, api_key=None, auth_type="api_key", api_host=None): if not api_key: return False - hosts = { - d.host.lower() - for d in get_custom_domains( - org, api_key=api_key, auth_type=auth_type, api_host=api_host - ) - if d.enabled and d.validated - } + if backend_kind is not None: + hosts = { + host.lower() + for host in get_format_domains( + org, + backend_kind, + api_key=api_key, + auth_type=auth_type, + api_host=api_host, + ) + } + else: + hosts = { + d.host.lower() + for d in get_custom_domains( + org, api_key=api_key, auth_type=auth_type, api_host=api_host + ) + if d.enabled and d.validated + } return hostname in hosts diff --git a/cloudsmith_cli/credential_helpers/docker/runtime.py b/cloudsmith_cli/credential_helpers/docker/runtime.py index 4200bbce..b078216c 100644 --- a/cloudsmith_cli/credential_helpers/docker/runtime.py +++ b/cloudsmith_cli/credential_helpers/docker/runtime.py @@ -12,6 +12,7 @@ import json import logging +from ..backends import BackendKind from ..common import is_cloudsmith_domain logger = logging.getLogger(__name__) @@ -47,6 +48,7 @@ def get_credentials(server_url, credential=None, api_host=None): api_key=credential.api_key, auth_type=getattr(credential, "auth_type", "api_key"), api_host=api_host, + backend_kind=BackendKind.DOCKER, ): return None From fd1624355c715328c22a7096812e7c9460d22d2b Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 16:31:53 +0100 Subject: [PATCH 18/21] test(credential-helper): consolidate tests to one-per-behavior Collapse 108 tests (45 + 63) down to 61 (34 + 27) by merging near-identical cases into parametrized tests and removing layer-duplicates. All five retained guards survive: broken-pipe boundary, legacy-cache format miss, uppercase-host casing, status str-not-Path serialisation, and graceful discovery failure. Co-Authored-By: Claude --- .../tests/commands/test_credential_helper.py | 1207 +++++--------- .../test_credential_helper_install.py | 1467 +++++------------ 2 files changed, 818 insertions(+), 1856 deletions(-) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py index cb419ab9..98cf0a5a 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper.py @@ -20,6 +20,7 @@ write_cache, ) from ....credential_helpers.docker.runtime import ( + _REFUSAL_MESSAGE, execute, get_credentials as helper_get_credentials, ) @@ -27,77 +28,86 @@ API_HOST = "https://api.cloudsmith.io" -class TestDockerRuntime: - """Unit tests for the transport-light runtime (execute + get_credentials).""" - - # ------------------------------------------------------------------ - # execute – get operation - # ------------------------------------------------------------------ - - def test_execute_get_success_returns_json(self): - """execute get → (0, json_string, None) when get_credentials returns a dict.""" - fake_creds = {"Username": "token", "Secret": "k_abc"} - stdin = io.StringIO("docker.cloudsmith.io") +# --------------------------------------------------------------------------- +# 1. runtime.execute — protocol matrix +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "operation,get_return,stdin_text,expected_code,expected_stdout,stderr_substr", + [ + # get-success: get_credentials returns a dict → (0, json, None) + ( + "get", + {"Username": "token", "Secret": "k_abc"}, + "docker.cloudsmith.io", + 0, + json.dumps({"Username": "token", "Secret": "k_abc"}), + None, + ), + # get-refusal: get_credentials returns None → (1, None, refusal) + ("get", None, "evil.example.com", 1, None, "Unable to retrieve credentials"), + # store: drains stdin, returns (0, None, None) + ("store", None, '{"ServerURL": "docker.cloudsmith.io"}', 0, None, None), + # erase: drains stdin, returns (0, None, None) + ("erase", None, '{"ServerURL": "docker.cloudsmith.io"}', 0, None, None), + # list: returns (0, '{}', None) + ("list", None, "", 0, "{}", None), + # unknown: returns (1, None, error containing operation name) + ("frobnicate", None, "", 1, None, "frobnicate"), + ], +) +def test_execute_protocol_matrix( + operation, get_return, stdin_text, expected_code, expected_stdout, stderr_substr +): + """execute() returns the expected (code, stdout, stderr) for each operation.""" + stdin = io.StringIO(stdin_text) + if operation == "get" and get_return is not None: with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" - ) as mock_get: - mock_get.return_value = fake_creds - code, stdout, stderr = execute("get", stdin) + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials", + return_value=get_return, + ): + code, stdout, stderr = execute(operation, stdin) + elif operation == "get": + with patch( + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials", + return_value=get_return, + ): + code, stdout, stderr = execute(operation, stdin) + else: + code, stdout, stderr = execute(operation, stdin) - assert code == 0 - assert json.loads(stdout) == fake_creds + assert code == expected_code + assert stdout == expected_stdout + if stderr_substr is None: assert stderr is None + else: + assert stderr_substr in stderr - def test_execute_get_refusal_returns_exit_1(self): - """execute get → (1, None, refusal_msg) when get_credentials returns None.""" - stdin = io.StringIO("evil.example.com") - with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" - ) as mock_get: - mock_get.return_value = None - code, stdout, stderr = execute("get", stdin) - - assert code == 1 - assert stdout is None - assert "Unable to retrieve credentials" in stderr +# --------------------------------------------------------------------------- +# 2. execute get-path boundary guards (broken-pipe + RuntimeError) +# --------------------------------------------------------------------------- - def test_execute_get_empty_stdin_returns_exit_1(self): - """execute get with empty stdin → (1, None, 'No server URL...').""" - stdin = io.StringIO("") - code, stdout, stderr = execute("get", stdin) - assert code == 1 - assert stdout is None - assert "No server URL provided" in stderr +@pytest.mark.parametrize("scenario", ["raising_get_credentials", "broken_pipe_stdin"]) +def test_execute_get_boundary_guard(scenario): + """Exceptions inside execute('get', ...) never escape — degrade to (1, None, refusal). - def test_execute_get_exception_is_caught_at_boundary(self): - """D17: a network/SDK error inside get_credentials must NOT escape execute. - - The protocol boundary degrades to a clean refusal (exit 1) so that - docker pull/push never sees a Python traceback. - """ + Retained guards: + - broken-pipe: an OSError from stdin.read() is caught at the protocol boundary. + - RuntimeError from get_credentials is caught the same way. + """ + if scenario == "raising_get_credentials": stdin = io.StringIO("docker.cloudsmith.io") - with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" - ) as mock_get: - mock_get.side_effect = RuntimeError("boom") + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials", + side_effect=RuntimeError("boom"), + ): code, stdout, stderr = execute("get", stdin) - - assert code == 1 - assert stdout is None - assert "Unable to retrieve credentials" in stderr - - def test_execute_get_broken_pipe_stdin_is_caught_at_boundary(self): - """A broken-pipe OSError from stdin.read() must not escape execute. - - The widened boundary covers the stdin read, so a broken pipe degrades - to a clean refusal (exit 1) rather than propagating an exception. - """ - from ....credential_helpers.docker.runtime import _REFUSAL_MESSAGE - + else: + # broken-pipe guard: stdin.read() itself raises an OSError class BrokenPipeStdin: def read(self): raise OSError("broken pipe") @@ -107,589 +117,327 @@ def read(self): "get", BrokenPipeStdin(), credential=credential, api_host=None ) - assert code == 1 - assert stdout is None - assert stderr == _REFUSAL_MESSAGE - - # ------------------------------------------------------------------ - # execute – write/no-op operations - # ------------------------------------------------------------------ - - @pytest.mark.parametrize("operation", ["store", "erase"]) - def test_execute_store_erase_returns_0_no_output(self, operation): - """store and erase drain stdin and return (0, None, None).""" - stdin = io.StringIO('{"ServerURL": "docker.cloudsmith.io"}') - code, stdout, stderr = execute(operation, stdin) - - assert code == 0 - assert stdout is None - assert stderr is None - - def test_execute_list_returns_empty_json_object(self): - """list always returns (0, '{}', None).""" - stdin = io.StringIO("") - code, stdout, stderr = execute("list", stdin) - - assert code == 0 - assert stdout == "{}" - assert stderr is None - - def test_execute_unknown_operation_returns_exit_1(self): - """An unrecognised operation name returns (1, None, error_message).""" - stdin = io.StringIO("") - code, stdout, stderr = execute("frobnicate", stdin) - - assert code == 1 - assert stdout is None - assert "Unknown operation" in stderr - assert "frobnicate" in stderr - - # ------------------------------------------------------------------ - # get_credentials - # ------------------------------------------------------------------ - - def test_get_credentials_returns_dict_for_cloudsmith_domain(self): - """get_credentials returns username+secret for a Cloudsmith domain.""" - credential = CredentialResult(api_key="k_xyz", source_name="test") - - with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.is_cloudsmith_domain" - ) as mock_is: - mock_is.return_value = True - result = helper_get_credentials( - "docker.cloudsmith.io", credential=credential - ) - - assert result == {"Username": "token", "Secret": "k_xyz"} - - def test_get_credentials_returns_none_when_no_credential(self): - """get_credentials returns None when credential is absent.""" - result = helper_get_credentials("docker.cloudsmith.io", credential=None) - assert result is None - - def test_get_credentials_returns_none_for_non_cloudsmith_domain(self): - """get_credentials returns None when is_cloudsmith_domain is False.""" - credential = CredentialResult(api_key="k_xyz", source_name="test") - - with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.is_cloudsmith_domain" - ) as mock_is: - mock_is.return_value = False - result = helper_get_credentials("evil.example.com", credential=credential) - - assert result is None - - -class TestDockerCredentialHelper: - """Test suite for the Docker credential helper CLI command.""" - - def test_get_credentials_for_cloudsmith_io(self, runner): - """`*.cloudsmith.io` URLs should return credentials JSON on stdout.""" - fake_creds = {"Username": "token", "Secret": "k_abc"} - + assert code == 1 + assert stdout is None + assert stderr == _REFUSAL_MESSAGE + + +# --------------------------------------------------------------------------- +# 3. get_credentials +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "server_url,credential,is_cloudsmith_return,expected", + [ + # cloudsmith domain + creds → dict + ( + "docker.cloudsmith.io", + CredentialResult(api_key="k_xyz", source_name="test"), + True, + {"Username": "token", "Secret": "k_xyz"}, + ), + # no credential → None (short-circuits before domain check) + ("docker.cloudsmith.io", None, None, None), + # non-cloudsmith domain → None + ( + "evil.example.com", + CredentialResult(api_key="k_xyz", source_name="test"), + False, + None, + ), + ], +) +def test_get_credentials(server_url, credential, is_cloudsmith_return, expected): + """get_credentials returns a creds dict for valid CS domains, None otherwise.""" + if is_cloudsmith_return is None: + # No domain check needed when credential is absent + result = helper_get_credentials(server_url, credential=credential) + else: with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" - ) as mock_get: - mock_get.return_value = fake_creds - result = runner.invoke( - docker, - args=["get"], - input="docker.cloudsmith.io", - catch_exceptions=False, - ) + "cloudsmith_cli.credential_helpers.docker.runtime.is_cloudsmith_domain", + return_value=is_cloudsmith_return, + ): + result = helper_get_credentials(server_url, credential=credential) - assert result.exit_code == 0 - assert json.dumps(fake_creds) in result.stdout - mock_get.assert_called_once() - called_args, _called_kwargs = mock_get.call_args - assert called_args[0] == "docker.cloudsmith.io" + assert result == expected - def test_no_arg_defaults_to_get(self, runner): - """Invoking docker with no OPERATION argument defaults to 'get'.""" - fake_creds = {"Username": "token", "Secret": "k_abc"} - with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" - ) as mock_get: - mock_get.return_value = fake_creds - result = runner.invoke( - docker, - args=[], - input="docker.cloudsmith.io", - catch_exceptions=False, - ) - - assert result.exit_code == 0 - assert json.dumps(fake_creds) in result.stdout +# --------------------------------------------------------------------------- +# 4. CLI wiring smoke test +# --------------------------------------------------------------------------- - def test_refuses_non_cloudsmith_domain(self, runner): - """Non-Cloudsmith URLs should exit 1 with an error message on stderr.""" - with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" - ) as mock_get: - mock_get.return_value = None - result = runner.invoke( - docker, - args=["get"], - input="evil.example.com", - catch_exceptions=False, - ) - assert result.exit_code == 1 - assert "Unable to retrieve credentials" in result.output - mock_get.assert_called_once() +def test_cli_no_arg_defaults_to_get(runner): + """Invoking docker with no OPERATION defaults to 'get', emitting creds JSON. - def test_empty_stdin_exits_1(self, runner): - """Empty stdin should exit 1 with a descriptive error on stderr.""" - with patch( - "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials" - ) as mock_get: - result = runner.invoke( - docker, args=["get"], input="", catch_exceptions=False - ) - - assert result.exit_code == 1 - assert "No server URL provided" in result.output - mock_get.assert_not_called() + Proves the click shim is correctly wired to execute(). + """ + fake_creds = {"Username": "token", "Secret": "k_abc"} - @pytest.mark.parametrize("operation", ["store", "erase"]) - def test_store_erase_exit_0_no_output(self, runner, operation): - """store and erase exit 0 and produce no output.""" + with patch( + "cloudsmith_cli.credential_helpers.docker.runtime.get_credentials", + return_value=fake_creds, + ): result = runner.invoke( docker, - args=[operation], - input='{"ServerURL": "docker.cloudsmith.io"}', + args=[], + input="docker.cloudsmith.io", catch_exceptions=False, ) - assert result.exit_code == 0 - assert result.output == "" + assert result.exit_code == 0 + assert json.dumps(fake_creds) in result.stdout - def test_list_prints_empty_json(self, runner): - """list exits 0 and prints '{}'.""" - result = runner.invoke(docker, args=["list"], input="", catch_exceptions=False) - assert result.exit_code == 0 - assert "{}" in result.output +def test_execute_get_empty_stdin_returns_exit_1(): + """execute get with empty stdin → (1, None, 'No server URL...').""" + stdin = io.StringIO("") + code, stdout, stderr = execute("get", stdin) - def test_custom_domain_with_cached_response(self, tmp_path, monkeypatch): - """A cached custom-domain entry should authorise credential issuance. + assert code == 1 + assert stdout is None + assert "No server URL provided" in stderr - This exercises the helper-level `get_credentials` (not the click command) - so the on-disk custom-domain cache lookup runs end to end. The click - command's wiring is covered by the other tests in this class. - """ - # Point the cache base at a per-test temp directory. - monkeypatch.setattr( - "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", - lambda: str(tmp_path), - ) - # is_cloudsmith_domain reads CLOUDSMITH_ORG from the environment. - monkeypatch.setenv("CLOUDSMITH_ORG", "acme") - - # Seed the cache file at the path the helper will read from. - cache_path = get_cache_path("acme") - assert cache_path.parent.exists(), "get_cache_path should create the dir" - write_cache( - cache_path, - [ - CustomDomain( - host="docker.acme.com", backend_kind=6, enabled=True, validated=True - ) - ], - ) - credential = CredentialResult(api_key="k_xyz", source_name="test") +# --------------------------------------------------------------------------- +# 5. BackendKind load-bearing values +# --------------------------------------------------------------------------- - # A cache hit must short-circuit before any SDK call. - def _boom(*_args, **_kwargs): - raise AssertionError( - "API call attempted despite a valid custom-domain cache" - ) - monkeypatch.setattr( - "cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains", - _boom, - ) +def test_backend_kind_values(): + """DOCKER == 6 and NPM == 9 are load-bearing (used as integer wire values).""" + assert BackendKind.DOCKER == 6 + assert BackendKind.NPM == 9 - result = helper_get_credentials( - "docker.acme.com", - credential=credential, - api_host="https://api.cloudsmith.io", - ) - - assert result == {"Username": "token", "Secret": "k_xyz"} - - -class TestBackendKind: - """Spot-check BackendKind enum values.""" - - def test_docker_is_6(self): - assert BackendKind.DOCKER == 6 - def test_npm_is_9(self): - assert BackendKind.NPM == 9 +# --------------------------------------------------------------------------- +# 6. get_custom_domains — HTTP status matrix +# --------------------------------------------------------------------------- - def test_deb_is_0(self): - assert BackendKind.DEB == 0 - def test_default_is_99(self): - assert BackendKind.DEFAULT == 99 +@pytest.fixture(autouse=True) +def _cache_dir(tmp_path, monkeypatch): + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + monkeypatch.setattr( + httpretty.core.fakesock.socket, + "shutdown", + lambda self, how: None, + raising=False, + ) -class TestGetCustomDomains: - """Exercise the SDK-backed custom-domains fetch path. - - These tests stub the v1 `GET /orgs/{org}/custom-domains/` endpoint that the - `cloudsmith_api` SDK calls. The on-disk cache base is redirected to a temp dir - per test. - """ - - @pytest.fixture(autouse=True) - def _cache_dir(self, tmp_path, monkeypatch): - monkeypatch.setattr( - "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", - lambda: str(tmp_path), - ) - # httpretty's fake socket has no shutdown(); urllib3 calls it during - # getresponse(). Stub it so requests succeed under httpretty. - monkeypatch.setattr( - httpretty.core.fakesock.socket, - "shutdown", - lambda self, how: None, - raising=False, - ) - - @httpretty.activate(allow_net_connect=False) - def test_success_builds_records_and_caches(self): - """A 200 response builds CustomDomain records and caches them.""" - body = [ - { - "host": "docker.acme.com", - "backend_kind": 6, - "domain_type": 1, - "enabled": True, - "validated": True, - }, - { - "host": "dl.acme.com", - "backend_kind": None, - "domain_type": 0, - "enabled": True, - "validated": True, - }, - { - "host": "", - "backend_kind": 6, - "domain_type": 1, - "enabled": True, - "validated": True, - }, # blank host is skipped - ] - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps(body), - status=200, - content_type="application/json", - ) - - domains = get_custom_domains("acme", api_key="k_abc", api_host=API_HOST) - - assert len(domains) == 2 - assert domains[0] == CustomDomain( - host="docker.acme.com", backend_kind=6, enabled=True, validated=True - ) - assert domains[1] == CustomDomain( - host="dl.acme.com", backend_kind=None, enabled=True, validated=True - ) - # Auth header proves the SDK auth path is exercised (X-Api-Key, not Bearer). +@pytest.mark.parametrize( + "status,expect_domains,expect_cached", + [ + # 200 → records built and cached + (200, True, True), + # 402 → [] and cached (feature not enabled) + (402, False, True), + # 404 → [] and cached (org not found) + (404, False, True), + # 403 → [] but NOT cached (may succeed after auth) + (403, False, False), + # 401 → [] but NOT cached (same branch as 403) + (401, False, False), + # 500 → [] and NOT cached + (500, False, False), + ], +) +@httpretty.activate(allow_net_connect=False) +def test_get_custom_domains_status_matrix( + tmp_path, monkeypatch, status, expect_domains, expect_cached +): + """get_custom_domains() caches or not based on HTTP status.""" + # redirect per-test (autouse fixture already set module-level path) + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + + if status == 200: + body = json.dumps( + [ + { + "host": "docker.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": True, + } + ] + ) + else: + body = json.dumps({"detail": "error"}) + + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=body, + status=status, + content_type="application/json", + ) + + result = get_custom_domains("acme", api_key="k_abc", api_host=API_HOST) + cache = read_cache(get_cache_path("acme")) + + if expect_domains: + assert len(result) == 1 + assert result[0].host == "docker.acme.com" + else: + assert result == [] + + if expect_cached: + assert cache is not None # [] is falsy but not None + else: + assert cache is None + + # For the 200 case specifically, verify the auth header (X-Api-Key) + if status == 200: assert httpretty.last_request().headers.get("X-Api-Key") == "k_abc" - @httpretty.activate(allow_net_connect=False) - def test_cache_round_trip(self): - """Fetched records are cached; a second call returns the same records from cache.""" - body = [ - { - "host": "docker.acme.com", - "backend_kind": 6, - "domain_type": 1, - "enabled": True, - "validated": True, - }, - ] - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps(body), - status=200, - content_type="application/json", - ) - - first = get_custom_domains("acme", api_key="k_abc", api_host=API_HOST) - - # Verify the cache contains structured records. - cached = read_cache(get_cache_path("acme")) - assert cached is not None - assert cached == first - assert cached[0].backend_kind == 6 - assert cached[0].enabled is True - assert cached[0].validated is True - - @httpretty.activate(allow_net_connect=False) - def test_bearer_auth_uses_authorization_header(self): - """A bearer credential sends an Authorization: Bearer header.""" - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps([]), - status=200, - content_type="application/json", - ) - - get_custom_domains( - "acme", api_key="tok123", auth_type="bearer", api_host=API_HOST - ) - - assert httpretty.last_request().headers.get("Authorization") == "Bearer tok123" - - @httpretty.activate(allow_net_connect=False) - def test_404_caches_empty(self): - """A 404 returns [] and caches the empty result to avoid repeat lookups.""" - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps({"detail": "Not found."}), - status=404, - content_type="application/json", - ) - - assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] - assert read_cache(get_cache_path("acme")) == [] - - @httpretty.activate(allow_net_connect=False) - def test_402_caches_empty(self): - """A 402 (feature not enabled) returns [] and caches the empty result.""" - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps({"detail": "Upgrade required."}), - status=402, - content_type="application/json", - ) - - assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] - assert read_cache(get_cache_path("acme")) == [] - - @httpretty.activate(allow_net_connect=False) - def test_403_does_not_cache(self): - """A 403 returns [] but does NOT cache (may succeed later once authed).""" - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps({"detail": "Forbidden."}), - status=403, - content_type="application/json", - ) - - assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] - assert read_cache(get_cache_path("acme")) is None - - @httpretty.activate(allow_net_connect=False) - def test_server_error_returns_empty_without_caching(self): - """A 500 returns [] without raising and without caching.""" - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps({"detail": "Boom."}), - status=500, - content_type="application/json", - ) - assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] - assert read_cache(get_cache_path("acme")) is None - - @httpretty.activate(allow_net_connect=False) - def test_401_does_not_cache(self): - """A 401 returns [] but does NOT cache (may succeed later once authed).""" - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps({"detail": "Unauthorized."}), - status=401, - content_type="application/json", - ) +# --------------------------------------------------------------------------- +# 7. get_custom_domains — cache edge cases +# --------------------------------------------------------------------------- - assert get_custom_domains("acme", api_key="k", api_host=API_HOST) == [] - assert read_cache(get_cache_path("acme")) is None - - def test_legacy_string_cache_is_a_miss(self, tmp_path): - """A cache file with a string-list 'domains' (old format) returns None.""" - import time - - cache_path = get_cache_path("acme") - legacy_data = { - "domains": ["docker.acme.com"], - "cached_at": time.time(), - } - cache_path.write_text(json.dumps(legacy_data), encoding="utf-8") - assert read_cache(cache_path) is None - - def test_empty_domains_cache_is_a_hit(self, tmp_path): - """A cache file with 'domains': [] returns [] (valid cached 'no domains').""" - import time - - cache_path = get_cache_path("acme") - empty_data = { - "domains": [], - "cached_at": time.time(), - } - cache_path.write_text(json.dumps(empty_data), encoding="utf-8") - assert read_cache(cache_path) == [] - - -class TestGetFormatDomains: - """Test get_format_domains filters by backend_kind, enabled, and validated.""" - - @pytest.fixture(autouse=True) - def _cache_dir(self, tmp_path, monkeypatch): - monkeypatch.setattr( - "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", - lambda: str(tmp_path), - ) - monkeypatch.setattr( - httpretty.core.fakesock.socket, - "shutdown", - lambda self, how: None, - raising=False, - ) - @httpretty.activate(allow_net_connect=False) - def test_returns_only_enabled_validated_docker_hosts(self): - """Only Docker domains that are both enabled and validated are returned.""" - body = [ - # Should be included: Docker, enabled, validated - { - "host": "docker.acme.com", - "backend_kind": 6, - "domain_type": 1, - "enabled": True, - "validated": True, - }, - # Excluded: different backend_kind (NPM = 9) - { - "host": "npm.acme.com", - "backend_kind": 9, - "domain_type": 1, - "enabled": True, - "validated": True, - }, - # Excluded: Docker but not enabled - { - "host": "docker2.acme.com", - "backend_kind": 6, - "domain_type": 1, - "enabled": False, - "validated": True, - }, - # Excluded: Docker but not validated - { - "host": "docker3.acme.com", - "backend_kind": 6, - "domain_type": 1, - "enabled": True, - "validated": False, - }, - # Excluded: backend_kind is None (download domain) - { - "host": "dl.acme.com", - "backend_kind": None, - "domain_type": 0, - "enabled": True, - "validated": True, - }, - ] - httpretty.register_uri( - httpretty.GET, - f"{API_HOST}/orgs/acme/custom-domains/", - body=json.dumps(body), - status=200, - content_type="application/json", - ) - - hosts = get_format_domains( - "acme", BackendKind.DOCKER, api_key="k", api_host=API_HOST - ) - - assert hosts == ["docker.acme.com"] - - -class TestIsCloudsmithDomain: - """Test is_cloudsmith_domain with standard and custom domains.""" - - @pytest.fixture(autouse=True) - def _cache_dir(self, tmp_path, monkeypatch): - monkeypatch.setattr( - "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", - lambda: str(tmp_path), - ) +@pytest.mark.parametrize( + "scenario,expected", + [ + # legacy string-list cache → miss (re-fetch required); retains legacy-cache guard + ("legacy_string_list", None), + # empty [] cache → hit (valid cached 'no domains') + ("empty_list", []), + ], +) +def test_get_custom_domains_cache_edge(tmp_path, monkeypatch, scenario, expected): + """Cache format edge cases: legacy string-list is a miss; empty list is a hit.""" + import time - def test_standard_cloudsmith_io_true(self): - """Standard *.cloudsmith.io domains are true without any API call.""" - from ....credential_helpers.common import is_cloudsmith_domain + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) - assert is_cloudsmith_domain("docker.cloudsmith.io") is True - assert is_cloudsmith_domain("dl.cloudsmith.io") is True + cache_path = get_cache_path("acme") - def test_standard_cloudsmith_com_true(self): - """Standard *.cloudsmith.com domains are true without any API call.""" - from ....credential_helpers.common import is_cloudsmith_domain + if scenario == "legacy_string_list": + data = {"domains": ["docker.acme.com"], "cached_at": time.time()} + else: + data = {"domains": [], "cached_at": time.time()} - assert is_cloudsmith_domain("docker.cloudsmith.com") is True + cache_path.write_text(json.dumps(data), encoding="utf-8") + assert read_cache(cache_path) == expected - def test_non_cloudsmith_false(self): - """Unrelated hostnames return False.""" - from ....credential_helpers.common import is_cloudsmith_domain - assert is_cloudsmith_domain("evil.example.com") is False +# --------------------------------------------------------------------------- +# 8. get_format_domains +# --------------------------------------------------------------------------- - def test_custom_enabled_validated_host_true(self, tmp_path, monkeypatch): - """An enabled+validated custom domain in cache returns True.""" - from ....credential_helpers.common import is_cloudsmith_domain - monkeypatch.setenv("CLOUDSMITH_ORG", "acme") +@httpretty.activate(allow_net_connect=False) +def test_get_format_domains_filters_correctly(tmp_path, monkeypatch): + """get_format_domains returns only enabled+validated Docker hosts. - cache_path = get_cache_path("acme") - write_cache( - cache_path, + Mixed body: docker enabled+validated (included), npm (excluded), + disabled docker (excluded), unvalidated docker (excluded), backend_kind=None (excluded). + """ + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + body = [ + # Included: Docker, enabled, validated + { + "host": "docker.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": True, + }, + # Excluded: NPM backend + { + "host": "npm.acme.com", + "backend_kind": 9, + "domain_type": 1, + "enabled": True, + "validated": True, + }, + # Excluded: disabled + { + "host": "docker2.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": False, + "validated": True, + }, + # Excluded: unvalidated + { + "host": "docker3.acme.com", + "backend_kind": 6, + "domain_type": 1, + "enabled": True, + "validated": False, + }, + # Excluded: backend_kind=None (download domain) + { + "host": "dl.acme.com", + "backend_kind": None, + "domain_type": 0, + "enabled": True, + "validated": True, + }, + ] + httpretty.register_uri( + httpretty.GET, + f"{API_HOST}/orgs/acme/custom-domains/", + body=json.dumps(body), + status=200, + content_type="application/json", + ) + + hosts = get_format_domains( + "acme", BackendKind.DOCKER, api_key="k", api_host=API_HOST + ) + + assert hosts == ["docker.acme.com"] + + +# --------------------------------------------------------------------------- +# 9. is_cloudsmith_domain +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "host,env_org,cached_domains,backend_kind,expected", + [ + # Standard *.cloudsmith.io → True (no API) + ("docker.cloudsmith.io", None, None, None, True), + ("dl.cloudsmith.io", None, None, None, True), + # Standard *.cloudsmith.com → True (no API) + ("docker.cloudsmith.com", None, None, None, True), + # Non-cloudsmith → False + ("evil.example.com", None, None, None, False), + # Custom enabled+validated → True + ( + "docker.acme.com", + "acme", [ CustomDomain( - host="docker.acme.com", - backend_kind=6, - enabled=True, - validated=True, + host="docker.acme.com", backend_kind=6, enabled=True, validated=True ) ], - ) - - assert ( - is_cloudsmith_domain( - "docker.acme.com", - api_key="k_abc", - api_host=API_HOST, - ) - is True - ) - - def test_custom_disabled_host_false(self, tmp_path, monkeypatch): - """A disabled custom domain is not treated as a Cloudsmith domain.""" - from ....credential_helpers.common import is_cloudsmith_domain - - monkeypatch.setenv("CLOUDSMITH_ORG", "acme") - - cache_path = get_cache_path("acme") - write_cache( - cache_path, + None, + True, + ), + # Custom disabled → False + ( + "docker.acme.com", + "acme", [ CustomDomain( host="docker.acme.com", @@ -698,26 +446,13 @@ def test_custom_disabled_host_false(self, tmp_path, monkeypatch): validated=True, ) ], - ) - - assert ( - is_cloudsmith_domain( - "docker.acme.com", - api_key="k_abc", - api_host=API_HOST, - ) - is False - ) - - def test_custom_enabled_not_validated_host_false(self, tmp_path, monkeypatch): - """An enabled but unvalidated custom domain is not a Cloudsmith domain.""" - from ....credential_helpers.common import is_cloudsmith_domain - - monkeypatch.setenv("CLOUDSMITH_ORG", "acme") - - cache_path = get_cache_path("acme") - write_cache( - cache_path, + None, + False, + ), + # Custom enabled but unvalidated → False + ( + "docker.acme.com", + "acme", [ CustomDomain( host="docker.acme.com", @@ -726,182 +461,104 @@ def test_custom_enabled_not_validated_host_false(self, tmp_path, monkeypatch): validated=False, ) ], - ) - - assert ( - is_cloudsmith_domain( - "docker.acme.com", - api_key="k_abc", - api_host=API_HOST, - ) - is False - ) - - # ------------------------------------------------------------------ - # backend_kind filtering - # ------------------------------------------------------------------ - - def test_backend_kind_docker_matches_docker_custom_domain( - self, tmp_path, monkeypatch - ): - """With backend_kind=DOCKER, a Docker custom domain (kind=6) returns True.""" - from ....credential_helpers.common import is_cloudsmith_domain - - monkeypatch.setenv("CLOUDSMITH_ORG", "acme") - cache_path = get_cache_path("acme") - write_cache( - cache_path, + None, + False, + ), + # backend_kind=DOCKER: docker custom domain → True + ( + "docker.acme.com", + "acme", [ CustomDomain( - host="docker.acme.com", - backend_kind=6, - enabled=True, - validated=True, + host="docker.acme.com", backend_kind=6, enabled=True, validated=True ) ], - ) - - assert ( - is_cloudsmith_domain( - "docker.acme.com", - api_key="k_abc", - api_host=API_HOST, - backend_kind=BackendKind.DOCKER, - ) - is True - ) - - def test_backend_kind_docker_rejects_npm_custom_domain(self, tmp_path, monkeypatch): - """With backend_kind=DOCKER, an NPM custom domain (kind=9) returns False.""" - from ....credential_helpers.common import is_cloudsmith_domain - - monkeypatch.setenv("CLOUDSMITH_ORG", "acme") - cache_path = get_cache_path("acme") - write_cache( - cache_path, + BackendKind.DOCKER, + True, + ), + # backend_kind=DOCKER: npm custom domain → False + ( + "npm.acme.com", + "acme", [ CustomDomain( - host="npm.acme.com", - backend_kind=9, - enabled=True, - validated=True, + host="npm.acme.com", backend_kind=9, enabled=True, validated=True ) ], - ) - - assert ( - is_cloudsmith_domain( - "npm.acme.com", - api_key="k_abc", - api_host=API_HOST, - backend_kind=BackendKind.DOCKER, - ) - is False - ) - - def test_backend_kind_docker_standard_domain_always_true(self): - """Standard *.cloudsmith.io is True even when backend_kind=DOCKER (no API call).""" - from ....credential_helpers.common import is_cloudsmith_domain - - assert ( - is_cloudsmith_domain( - "docker.cloudsmith.io", - backend_kind=BackendKind.DOCKER, - ) - is True - ) - assert ( - is_cloudsmith_domain( - "something.cloudsmith.io", - backend_kind=BackendKind.DOCKER, - ) - is True - ) - - def test_backend_kind_none_default_matches_any_format(self, tmp_path, monkeypatch): - """backend_kind=None (default) accepts any enabled+validated custom domain.""" - from ....credential_helpers.common import is_cloudsmith_domain - - monkeypatch.setenv("CLOUDSMITH_ORG", "acme") - cache_path = get_cache_path("acme") - write_cache( - cache_path, + BackendKind.DOCKER, + False, + ), + # UPPERCASE custom host row with backend_kind=DOCKER — guards the + # get_format_domains() casing path (the branch the lowercase fix touched) + ( + "DOCKER.ACME.COM", + "acme", [ CustomDomain( - host="npm.acme.com", - backend_kind=9, - enabled=True, - validated=True, + host="docker.acme.com", backend_kind=6, enabled=True, validated=True ) ], - ) + BackendKind.DOCKER, + True, + ), + ], +) +def test_is_cloudsmith_domain( + tmp_path, monkeypatch, host, env_org, cached_domains, backend_kind, expected +): + """is_cloudsmith_domain returns correct bool for standard, custom, and edge cases.""" + from ....credential_helpers.common import is_cloudsmith_domain - # Default (no backend_kind) still returns True for any format - assert ( - is_cloudsmith_domain( - "npm.acme.com", - api_key="k_abc", - api_host=API_HOST, - ) - is True - ) + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + if env_org: + monkeypatch.setenv("CLOUDSMITH_ORG", env_org) + else: + monkeypatch.delenv("CLOUDSMITH_ORG", raising=False) -class TestDockerRuntimeBackendKindFiltering: - """Tests that the Docker runtime refuses non-Docker custom domains.""" + if cached_domains is not None: + write_cache(get_cache_path(env_org), cached_domains) - @pytest.fixture(autouse=True) - def _cache_dir(self, tmp_path, monkeypatch): - monkeypatch.setattr( - "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", - lambda: str(tmp_path), - ) - monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + kwargs = {"api_key": "k_abc", "api_host": API_HOST} + if backend_kind is not None: + kwargs["backend_kind"] = backend_kind - def test_get_credentials_refuses_npm_custom_domain(self, tmp_path): - """get_credentials returns None for an NPM custom domain (not a Docker registry).""" - cache_path = get_cache_path("acme") - write_cache( - cache_path, - [ - CustomDomain( - host="npm.acme.com", - backend_kind=9, - enabled=True, - validated=True, - ) - ], - ) + result = is_cloudsmith_domain(host, **kwargs) + assert result is expected - credential = CredentialResult(api_key="k_xyz", source_name="test") - result = helper_get_credentials( - "npm.acme.com", - credential=credential, - api_host=API_HOST, - ) - assert result is None +# --------------------------------------------------------------------------- +# 10. Docker runtime backend_kind wiring +# --------------------------------------------------------------------------- - def test_get_credentials_serves_docker_custom_domain(self, tmp_path): - """get_credentials returns creds for a Docker custom domain (backend_kind=6).""" - cache_path = get_cache_path("acme") - write_cache( - cache_path, - [ - CustomDomain( - host="docker.acme.com", - backend_kind=6, - enabled=True, - validated=True, - ) - ], - ) - credential = CredentialResult(api_key="k_xyz", source_name="test") - result = helper_get_credentials( - "docker.acme.com", - credential=credential, - api_host=API_HOST, - ) +def test_get_credentials_refuses_npm_custom_domain(tmp_path, monkeypatch): + """get_credentials for an NPM custom domain → None (runtime passes BackendKind.DOCKER). + + Proves the runtime passes backend_kind=DOCKER to is_cloudsmith_domain. + """ + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + monkeypatch.setenv("CLOUDSMITH_ORG", "acme") + + cache_path = get_cache_path("acme") + write_cache( + cache_path, + [ + CustomDomain( + host="npm.acme.com", backend_kind=9, enabled=True, validated=True + ) + ], + ) + + credential = CredentialResult(api_key="k_xyz", source_name="test") + result = helper_get_credentials( + "npm.acme.com", credential=credential, api_host=API_HOST + ) - assert result == {"Username": "token", "Secret": "k_xyz"} + assert result is None diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index 46e59df6..af50a599 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -1,5 +1,4 @@ # Copyright 2026 Cloudsmith Ltd -# pylint: disable=too-many-lines """Tests for credential-helper install/uninstall/list commands and launchers.""" from __future__ import annotations @@ -33,696 +32,323 @@ def runner(): # --------------------------------------------------------------------------- -# write_launcher / remove_launcher — Unix +# 1. write_launcher — unix and windows # --------------------------------------------------------------------------- -class TestWriteLauncherUnix: - """Tests for write_launcher on Unix (os.name == 'posix').""" +@pytest.mark.parametrize("platform", ["unix", "windows"]) +def test_write_launcher(tmp_path, monkeypatch, platform): + """write_launcher produces exact content and correct mode for unix and windows. - def test_content_is_correct(self, tmp_path): - """Launcher content is exactly the exec-forwarding shell script.""" - dest = write_launcher( - tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", - ) - expected = '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n' - assert dest.read_text(encoding="utf-8") == expected + Retained guard: exact Windows .cmd content (CRLF, @echo off, %*). + """ + import cloudsmith_cli.credential_helpers.launchers as _launchers_mod - def test_mode_is_755(self, tmp_path): - """Launcher is created with mode 0o755.""" + if platform == "windows": + monkeypatch.setattr(_launchers_mod.os, "name", "nt") dest = write_launcher( tmp_path, "docker-credential-cloudsmith", "cloudsmith credential-helper docker", ) - mode = dest.stat().st_mode - assert stat.S_IMODE(mode) == 0o755 - - def test_returns_path_without_extension(self, tmp_path): - """Returned path has no extension on Unix.""" + assert str(dest).endswith(".cmd") + raw = Path(str(dest)).read_bytes() + assert raw == b"@echo off\r\ncloudsmith credential-helper docker %*\r\n" + else: dest = write_launcher( - tmp_path, "my-helper", "cloudsmith credential-helper docker" - ) - assert dest.name == "my-helper" - - def test_creates_bin_dir_if_absent(self, tmp_path): - """write_launcher creates bin_dir if it does not yet exist.""" - new_dir = tmp_path / "newdir" / "bin" - assert not new_dir.exists() - write_launcher(new_dir, "my-helper", "cloudsmith credential-helper docker") - assert new_dir.is_dir() - - -class TestRemoveLauncherUnix: - """Tests for remove_launcher on Unix.""" - - def test_returns_true_when_file_existed(self, tmp_path): - """remove_launcher returns True when a file was present and deleted.""" - write_launcher( - tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", - ) - result = remove_launcher(tmp_path, "docker-credential-cloudsmith") - assert result is True - - def test_returns_false_when_file_absent(self, tmp_path): - """remove_launcher returns False when no launcher file exists.""" - result = remove_launcher(tmp_path, "docker-credential-cloudsmith") - assert result is False - - def test_file_is_gone_after_remove(self, tmp_path): - """After remove_launcher the file no longer exists on disk.""" - write_launcher( tmp_path, "docker-credential-cloudsmith", "cloudsmith credential-helper docker", ) - remove_launcher(tmp_path, "docker-credential-cloudsmith") - assert not (tmp_path / "docker-credential-cloudsmith").exists() + expected = '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n' + assert dest.read_text(encoding="utf-8") == expected + assert stat.S_IMODE(dest.stat().st_mode) == 0o755 # --------------------------------------------------------------------------- -# write_launcher — Windows simulation +# 2. remove_launcher # --------------------------------------------------------------------------- -class TestWriteLauncherWindows: - """Tests for write_launcher when os.name == 'nt'. +def test_remove_launcher(tmp_path): + """remove_launcher returns True + file gone when present, False when absent.""" + write_launcher( + tmp_path, + "docker-credential-cloudsmith", + "cloudsmith credential-helper docker", + ) + assert remove_launcher(tmp_path, "docker-credential-cloudsmith") is True + assert not (tmp_path / "docker-credential-cloudsmith").exists() - On non-Windows systems we cannot instantiate a WindowsPath, so we test - the string content by inspecting the file via the returned path string and - verifying the name suffix—the actual file is created with a str join rather - than a WindowsPath object on macOS/Linux CI. - """ - - def test_creates_cmd_file(self, tmp_path, monkeypatch): - """On Windows, write_launcher creates a .cmd file.""" - import cloudsmith_cli.credential_helpers.launchers as _launchers_mod - - monkeypatch.setattr(_launchers_mod.os, "name", "nt") - dest = write_launcher( - tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", - ) - assert str(dest).endswith(".cmd") - - def test_cmd_content(self, tmp_path, monkeypatch): - """On Windows, .cmd content is byte-exact for correct Docker credential parsing.""" - import cloudsmith_cli.credential_helpers.launchers as _launchers_mod - - monkeypatch.setattr(_launchers_mod.os, "name", "nt") - dest = write_launcher( - tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", - ) - # Read raw bytes to avoid universal-newline translation on macOS/Linux - raw = Path(str(dest)).read_bytes() - assert raw == b"@echo off\r\ncloudsmith credential-helper docker %*\r\n" + # Second call: file is gone now + assert remove_launcher(tmp_path, "docker-credential-cloudsmith") is False # --------------------------------------------------------------------------- -# resolve_bin_dir +# 3. resolve_bin_dir # --------------------------------------------------------------------------- -class TestResolveBinDir: - """Tests for resolve_bin_dir resolution logic.""" +@pytest.mark.parametrize( + "scenario", ["override", "user_bin_fallback", "windows_user_bin"] +) +def test_resolve_bin_dir(tmp_path, monkeypatch, scenario): + """resolve_bin_dir respects an override, falls back to user-bin on posix/windows.""" + import cloudsmith_cli.credential_helpers.launchers as _launchers_mod - def test_override_is_respected(self, tmp_path): - """An explicit override path is returned as-is.""" + if scenario == "override": result = resolve_bin_dir(str(tmp_path)) assert result == tmp_path - def test_falls_back_to_user_bin_when_no_writable_cloudsmith( - self, tmp_path, monkeypatch - ): - """Falls back to ~/.local/bin when cloudsmith is not found and bin is not writable.""" - import cloudsmith_cli.credential_helpers.launchers as _launchers_mod - - # Patch shutil.which inside the launchers module + elif scenario == "user_bin_fallback": monkeypatch.setattr(_launchers_mod.shutil, "which", lambda _name: None) - # Patch Path.home() to point at tmp_path monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) monkeypatch.setattr(_launchers_mod.os, "name", "posix") - # Make os.access always return False so no fallback dir looks writable monkeypatch.setattr(_launchers_mod.os, "access", lambda _path, _mode: False) - result = resolve_bin_dir() assert result == tmp_path / ".local" / "bin" - def test_falls_back_to_windows_user_bin(self, tmp_path, monkeypatch): - """On Windows, falls back to %LOCALAPPDATA%/Cloudsmith/bin.""" - import cloudsmith_cli.credential_helpers.launchers as _launchers_mod - + else: # windows_user_bin monkeypatch.setattr(_launchers_mod.shutil, "which", lambda _name: None) monkeypatch.setattr(_launchers_mod.os, "name", "nt") monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - # Make no directory look writable monkeypatch.setattr(_launchers_mod.os, "access", lambda _path, _mode: False) - result = resolve_bin_dir() - # Compare normalised paths to handle cross-platform separator differences - # when testing Windows branch on macOS/Linux result_str = str(result).replace("\\", "/") expected_str = str(tmp_path / "Cloudsmith" / "bin").replace("\\", "/") assert result_str == expected_str -class TestIsOnPath: - """Tests for is_on_path.""" - - def test_directory_on_path(self, tmp_path, monkeypatch): - """A directory that appears in PATH is detected correctly.""" - monkeypatch.setenv("PATH", str(tmp_path)) - assert is_on_path(tmp_path) is True - - def test_directory_not_on_path(self, tmp_path, monkeypatch): - """A directory absent from PATH returns False.""" - monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") - assert is_on_path(tmp_path) is False - - def test_normalisation_handles_trailing_slash(self, tmp_path, monkeypatch): - """Trailing slashes in PATH entries are normalised correctly.""" - monkeypatch.setenv("PATH", str(tmp_path) + os.sep) - assert is_on_path(tmp_path) is True - - # --------------------------------------------------------------------------- -# DockerInstaller.install +# 4. is_on_path # --------------------------------------------------------------------------- -class TestDockerInstallerInstall: - """Tests for DockerInstaller.install.""" - - def _make_docker_config(self, docker_dir: Path, data: dict) -> Path: - """Write a config.json to *docker_dir* and return its path.""" - docker_dir.mkdir(parents=True, exist_ok=True) - cfg = docker_dir / "config.json" - cfg.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - return cfg +def test_is_on_path(tmp_path, monkeypatch): + """is_on_path returns True when dir is in PATH, False when absent.""" + monkeypatch.setenv("PATH", str(tmp_path)) + assert is_on_path(tmp_path) is True - def test_sets_default_host(self, tmp_path, monkeypatch): - """install sets credHelpers[docker.cloudsmith.io]=cloudsmith.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) - - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir)) - - cfg = json.loads((docker_dir / "config.json").read_text()) - assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") + assert is_on_path(tmp_path) is False - def test_sets_additional_domain(self, tmp_path, monkeypatch): - """install also sets credHelpers for --domain entries.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir), domains=("my.registry.example.com",)) +# --------------------------------------------------------------------------- +# 5. DockerInstaller.install +# --------------------------------------------------------------------------- - cfg = json.loads((docker_dir / "config.json").read_text()) - assert cfg["credHelpers"]["my.registry.example.com"] == "cloudsmith" - def test_preserves_foreign_keys(self, tmp_path, monkeypatch): - """Foreign keys in auths and credHelpers are not touched.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" +def test_docker_installer_install(tmp_path, monkeypatch): + """install sets default+extra domains, preserves foreign entries, writes the launcher.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) - # Seed a config with existing foreign data - self._make_docker_config( - docker_dir, + # Seed a config with foreign data that must be preserved + docker_dir.mkdir(parents=True) + (docker_dir / "config.json").write_text( + json.dumps( { "auths": {"ghcr.io": {"auth": "dG9rZW4="}}, "credHelpers": {"ghcr.io": "gh"}, }, + indent=2, ) + + "\n", + encoding="utf-8", + ) - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir)) - - cfg = json.loads((docker_dir / "config.json").read_text()) - assert cfg["auths"] == {"ghcr.io": {"auth": "dG9rZW4="}} - assert cfg["credHelpers"]["ghcr.io"] == "gh" - - def test_writes_launcher(self, tmp_path, monkeypatch): - """install writes a launcher script to bin_dir.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir)) - - launcher = bin_dir / "docker-credential-cloudsmith" - assert launcher.exists() - - def test_creates_bak_file(self, tmp_path, monkeypatch): - """install creates a .bak backup when config.json already exists.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir), domains=("my.registry.example.com",)) - # Seed existing config - self._make_docker_config(docker_dir, {"auths": {}}) + cfg = json.loads((docker_dir / "config.json").read_text()) - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir)) - - bak = docker_dir / "config.json.bak" - assert bak.exists() + # Default host registered + assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + # Extra --domain host registered + assert cfg["credHelpers"]["my.registry.example.com"] == "cloudsmith" + # Foreign entries preserved + assert cfg["auths"] == {"ghcr.io": {"auth": "dG9rZW4="}} + assert cfg["credHelpers"]["ghcr.io"] == "gh" + # Launcher written + assert (bin_dir / "docker-credential-cloudsmith").exists() # --------------------------------------------------------------------------- -# DockerInstaller.install — dry_run +# 6. install --dry-run # --------------------------------------------------------------------------- -class TestDockerInstallerDryRun: - """Tests for DockerInstaller.install with dry_run=True.""" - - def test_no_launcher_written(self, tmp_path, monkeypatch): - """dry_run=True does NOT write a launcher file.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" +def test_docker_installer_dry_run(tmp_path, monkeypatch): + """dry_run=True: no launcher written, config.json absent, returns planned strings.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir), dry_run=True) - - assert not (bin_dir / "docker-credential-cloudsmith").exists() - - def test_config_json_not_modified(self, tmp_path, monkeypatch): - """dry_run=True does NOT modify config.json.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir), dry_run=True) + installer = DockerInstaller() + actions = installer.install(bin_dir=str(bin_dir), dry_run=True) - assert not (docker_dir / "config.json").exists() - - def test_returns_planned_action_strings(self, tmp_path, monkeypatch): - """dry_run=True returns strings describing what would be done.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - installer = DockerInstaller() - actions = installer.install(bin_dir=str(bin_dir), dry_run=True) - - assert any("would write launcher" in a for a in actions) - assert any("docker.cloudsmith.io" in a for a in actions) - - -class TestDockerInstallerIdempotent: - """Tests for idempotent second-run behaviour.""" - - def test_second_install_no_change(self, tmp_path, monkeypatch): - """Running install twice does not change config.json the second time.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir)) - - mtime_before = (docker_dir / "config.json").stat().st_mtime - - # Second run — config should be considered up-to-date - actions = installer.install(bin_dir=str(bin_dir)) - - mtime_after = (docker_dir / "config.json").stat().st_mtime - assert mtime_before == mtime_after - assert any("already up to date" in a for a in actions) + assert not (bin_dir / "docker-credential-cloudsmith").exists() + assert not (docker_dir / "config.json").exists() + assert any("would write launcher" in a for a in actions) + assert any("docker.cloudsmith.io" in a for a in actions) # --------------------------------------------------------------------------- -# DockerInstaller.uninstall +# 7. install idempotent # --------------------------------------------------------------------------- -class TestDockerInstallerUninstall: - """Tests for DockerInstaller.uninstall.""" - - def test_removes_cloudsmith_entries_only(self, tmp_path, monkeypatch): - """uninstall removes cloudsmith entries but leaves foreign helpers.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - # Seed a mixed config - docker_dir.mkdir(parents=True) - cfg_path = docker_dir / "config.json" - cfg_path.write_text( - json.dumps( - { - "credHelpers": { - "docker.cloudsmith.io": "cloudsmith", - "ghcr.io": "gh", - } - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - - with patch( - "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", - return_value=bin_dir, - ): - installer = DockerInstaller() - installer.uninstall() - - cfg = json.loads(cfg_path.read_text()) - # cloudsmith entry removed - assert "docker.cloudsmith.io" not in cfg.get("credHelpers", {}) - # foreign entry kept - assert cfg["credHelpers"]["ghcr.io"] == "gh" - - def test_removes_launcher(self, tmp_path, monkeypatch): - """uninstall removes the launcher binary if it exists.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - # Write launcher manually - bin_dir.mkdir(parents=True) - launcher = bin_dir / "docker-credential-cloudsmith" - launcher.write_text("#!/bin/sh\n", encoding="utf-8") - - with patch( - "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", - return_value=bin_dir, - ): - installer = DockerInstaller() - installer.uninstall() - - assert not launcher.exists() - - def test_uninstall_dry_run_writes_nothing(self, tmp_path, monkeypatch): - """uninstall with dry_run=True makes no filesystem changes.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - bin_dir.mkdir(parents=True) - launcher = bin_dir / "docker-credential-cloudsmith" - launcher.write_text("#!/bin/sh\n", encoding="utf-8") - - docker_dir.mkdir(parents=True) - cfg_path = docker_dir / "config.json" - data = {"credHelpers": {"docker.cloudsmith.io": "cloudsmith"}} - cfg_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - - with patch( - "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", - return_value=bin_dir, - ): - installer = DockerInstaller() - actions = installer.uninstall(dry_run=True) - - # Launcher still present, config unchanged - assert launcher.exists() - assert json.loads(cfg_path.read_text()) == data - assert any("would remove" in a for a in actions) - - def test_custom_bin_dir_launcher_is_removed(self, tmp_path, monkeypatch): - """uninstall(bin_dir=) removes the launcher installed there.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - custom_bin_dir = tmp_path / "custom_bin" - installer = DockerInstaller() - installer.install(bin_dir=str(custom_bin_dir)) - launcher = custom_bin_dir / "docker-credential-cloudsmith" - assert launcher.exists(), "Precondition: launcher must exist after install" - installer.uninstall(bin_dir=str(custom_bin_dir)) - assert not launcher.exists(), "Launcher must be removed after uninstall" - - def test_uninstall_wrong_bin_dir_leaves_launcher_intact( - self, tmp_path, monkeypatch - ): - """uninstall(bin_dir=) reports nothing-to-remove; launcher in original dir stays.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - custom_bin_dir = tmp_path / "custom_bin" - installer = DockerInstaller() - installer.install(bin_dir=str(custom_bin_dir)) - launcher = custom_bin_dir / "docker-credential-cloudsmith" - assert launcher.exists(), "Precondition: launcher must exist after install" - other_dir = tmp_path / "other_bin" - other_dir.mkdir(parents=True) - actions = installer.uninstall(bin_dir=str(other_dir)) - assert launcher.exists(), "Launcher in custom_bin must remain untouched" - assert any( - "nothing to remove" in a for a in actions - ), f"Expected 'nothing to remove' in actions, got: {actions}" - +def test_docker_installer_idempotent(tmp_path, monkeypatch): + """Second install run reports no change (config mtime unchanged, 'already up to date').""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" -# --------------------------------------------------------------------------- -# DockerInstaller.status() return-type contract -# --------------------------------------------------------------------------- + installer = DockerInstaller() + installer.install(bin_dir=str(bin_dir)) + mtime_before = (docker_dir / "config.json").stat().st_mtime + actions = installer.install(bin_dir=str(bin_dir)) + mtime_after = (docker_dir / "config.json").stat().st_mtime -class TestDockerInstallerStatusReturnType: - """Unit assertions on the type contract of DockerInstaller.status(). - - Fix 3: status()["launcher"] must be str or None — never a pathlib.Path — - so that `list -F json` can serialise the value without error. - """ - - def test_launcher_is_none_when_not_installed(self, tmp_path, monkeypatch): - """When no launcher file exists, status()["launcher"] is None.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - with patch( - "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", - return_value=tmp_path / "bin", - ): - installer = DockerInstaller() - result = installer.status() - - assert result["launcher"] is None - - def test_launcher_is_str_when_installed(self, tmp_path, monkeypatch): - """When a launcher file exists, status()["launcher"] is a str, not a Path.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - # Write a real launcher so status() finds it - installer = DockerInstaller() - installer.install(bin_dir=str(bin_dir)) - - with patch( - "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", - return_value=bin_dir, - ): - result = installer.status() - - launcher = result["launcher"] - assert launcher is not None, "Expected a launcher path after install" - assert isinstance( - launcher, str - ), f"status()['launcher'] must be str, got {type(launcher).__name__!r}" - # Sanity-check the path points to the real launcher file - assert launcher.endswith("docker-credential-cloudsmith") - - def test_launcher_never_a_path_object(self, tmp_path, monkeypatch): - """status()["launcher"] is never a pathlib.Path instance regardless of install state.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - installer = DockerInstaller() - # Check before install - with patch( - "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", - return_value=bin_dir, - ): - before = installer.status() - assert not isinstance(before["launcher"], Path) - - # Install, then check again - installer.install(bin_dir=str(bin_dir)) - with patch( - "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", - return_value=bin_dir, - ): - after = installer.status() - assert not isinstance(after["launcher"], Path) + assert mtime_before == mtime_after + assert any("already up to date" in a for a in actions) # --------------------------------------------------------------------------- -# manage CLI (CliRunner) +# 8. uninstall # --------------------------------------------------------------------------- -class TestManageCLI: - """Tests for the install/uninstall/list Click commands via CliRunner.""" +def test_docker_installer_uninstall(tmp_path, monkeypatch): + """uninstall removes only cloudsmith entries (foreign kept) + removes launcher. - def test_install_docker_dry_run_exits_0(self, runner, tmp_path, monkeypatch): - """install docker --dry-run exits 0 and prints a plan.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - - from ....cli.commands.credential_helper.manage import install_cmd - - result = runner.invoke( - install_cmd, - ["docker", "--dry-run", "--bin-dir", str(tmp_path / "bin")], + Also verifies --bin-dir: install to custom dir, uninstall with same --bin-dir removes it. + """ + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + custom_bin_dir = tmp_path / "custom_bin" + + # Install to a custom bin dir + installer = DockerInstaller() + docker_dir.mkdir(parents=True) + cfg_path = docker_dir / "config.json" + cfg_path.write_text( + json.dumps( + { + "credHelpers": { + "docker.cloudsmith.io": "cloudsmith", + "ghcr.io": "gh", + } + }, + indent=2, ) + + "\n", + encoding="utf-8", + ) + # Install launcher to custom_bin_dir + installer.install(bin_dir=str(custom_bin_dir)) + launcher = custom_bin_dir / "docker-credential-cloudsmith" + assert launcher.exists(), "Precondition: launcher must exist after install" - assert result.exit_code == 0, result.output - assert "would" in result.output.lower() or "dry run" in result.output.lower() - - def test_install_unknown_helper_exits_nonzero(self, runner): - """install with an unknown helper name exits non-zero with an error.""" - from ....cli.commands.credential_helper.manage import install_cmd - - result = runner.invoke(install_cmd, ["badhelper"]) - - assert result.exit_code != 0 - - def test_uninstall_unknown_helper_exits_nonzero(self, runner): - """uninstall with an unknown helper name exits non-zero.""" - from ....cli.commands.credential_helper.manage import uninstall_cmd - - result = runner.invoke(uninstall_cmd, ["badhelper"]) - - assert result.exit_code != 0 - - def test_list_exits_0(self, runner, tmp_path, monkeypatch): - """list exits 0 and shows the docker helper entry.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + # Uninstall — removes cloudsmith keys and launcher + installer.uninstall(bin_dir=str(custom_bin_dir)) - from ....cli.commands.credential_helper.manage import list_cmd - - result = runner.invoke(list_cmd, []) - - assert result.exit_code == 0, result.output - assert "docker" in result.output + cfg = json.loads(cfg_path.read_text()) + assert "docker.cloudsmith.io" not in cfg.get("credHelpers", {}) + assert cfg["credHelpers"]["ghcr.io"] == "gh" + assert not launcher.exists() # --------------------------------------------------------------------------- -# PATH warning +# 9. DockerInstaller.status — str-not-Path guard # --------------------------------------------------------------------------- -class TestPathWarning: - """Tests that a WARNING action is emitted when bin_dir is not on PATH.""" - - def test_warning_fires_when_bin_dir_not_on_path(self, tmp_path, monkeypatch): - """install returns a WARNING action when target dir is not on PATH.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - - # Keep PATH pointing somewhere else so bin_dir is definitely absent - monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") - - installer = DockerInstaller() - actions = installer.install(bin_dir=str(bin_dir)) - - warning_actions = [a for a in actions if a.startswith("WARNING")] - assert warning_actions, f"Expected a WARNING action, got: {actions}" - assert any("PATH" in a for a in warning_actions) - - -# --------------------------------------------------------------------------- -# Unwritable directory → clean ClickException (no raw traceback) -# --------------------------------------------------------------------------- +def test_docker_installer_status_type_contract(tmp_path, monkeypatch): + """status()['launcher'] is str when installed and None when not — never a Path. + Retained guard: the -F json Path-serialization regression. + """ + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" -@pytest.mark.skipif( - os.name != "posix" or (hasattr(os, "geteuid") and os.geteuid() == 0), - reason="permission test only meaningful on POSIX as non-root", -) -class TestUnwritableDirCleanError: - """Tests that an unwritable bin_dir surfaces as a ClickException, not a raw OSError.""" + installer = DockerInstaller() - def test_unwritable_bin_dir_gives_click_exception( - self, runner, tmp_path, monkeypatch + # Before install: launcher is None + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=bin_dir, ): - """install with an unwritable --bin-dir exits non-zero without a bare OSError.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - - from ....cli.commands.credential_helper.manage import install_cmd + result_before = installer.status() - ro_dir = tmp_path / "readonly" - ro_dir.mkdir() - ro_dir.chmod(0o500) + assert result_before["launcher"] is None + assert not isinstance(result_before["launcher"], Path) - try: - result = runner.invoke( - install_cmd, - ["docker", "--bin-dir", str(ro_dir)], - ) - finally: - # Restore permissions so pytest can clean up tmp_path - ro_dir.chmod(0o700) + # After install: launcher is a non-None str + installer.install(bin_dir=str(bin_dir)) + with patch( + "cloudsmith_cli.credential_helpers.docker.installer.resolve_bin_dir", + return_value=bin_dir, + ): + result_after = installer.status() - assert result.exit_code != 0 - # The exception path should be a SystemExit (via ClickException), not a - # bare OSError escaping the command. - assert not isinstance( - result.exception, OSError - ), f"Raw OSError escaped: {result.exception}" + launcher = result_after["launcher"] + assert launcher is not None + assert isinstance( + launcher, str + ), f"status()['launcher'] must be str, got {type(launcher).__name__!r}" + assert launcher.endswith("docker-credential-cloudsmith") + assert not isinstance(launcher, Path) # --------------------------------------------------------------------------- -# Custom-domain autodiscovery +# 10. autodiscovery # --------------------------------------------------------------------------- -# Import path where installer imports get_format_domains (used for monkeypatching) _INSTALLER_GET_FORMAT_DOMAINS = ( "cloudsmith_cli.credential_helpers.docker.installer.get_format_domains" ) -class TestDockerInstallerAutodiscovery: - """Tests for DockerInstaller.install custom-domain autodiscovery.""" +@pytest.mark.parametrize( + "scenario", + [ + "discovery_on", + "no_discover", + "missing_org", + "missing_api_key", + "discovery_raises", + ], +) +def test_autodiscovery(tmp_path, monkeypatch, scenario): + """Autodiscovery matrix: registered, skipped, defaults-only, or graceful failure. - def test_discovery_on_registers_discovered_domains(self, tmp_path, monkeypatch): - """When discover=True and org+api_key present, discovered domains are registered.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) + Retained guard: graceful-discovery-failure (exception → WARNING, no crash). + """ + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", str(bin_dir)) + if scenario == "discovery_on": monkeypatch.setattr( _INSTALLER_GET_FORMAT_DOMAINS, lambda *_a, **_kw: ["docker.acme.com"], ) - installer = DockerInstaller() actions = installer.install( - bin_dir=str(bin_dir), - discover=True, - org="acme", - api_key="k_test", + bin_dir=str(bin_dir), discover=True, org="acme", api_key="k_test" ) - cfg = json.loads((docker_dir / "config.json").read_text()) assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" assert cfg["credHelpers"]["docker.acme.com"] == "cloudsmith" assert any("discovered" in a and "1" in a for a in actions) - def test_no_discover_skips_get_format_domains(self, tmp_path, monkeypatch): - """When discover=False, get_format_domains is never called.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) - + elif scenario == "no_discover": called = [] def _should_not_be_called(*_a, **_kw): @@ -730,28 +356,16 @@ def _should_not_be_called(*_a, **_kw): return [] monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called) - installer = DockerInstaller() installer.install( - bin_dir=str(bin_dir), - discover=False, - org="acme", - api_key="k_test", + bin_dir=str(bin_dir), discover=False, org="acme", api_key="k_test" ) - assert not called, "get_format_domains must not be called when discover=False" cfg = json.loads((docker_dir / "config.json").read_text()) assert "docker.cloudsmith.io" in cfg["credHelpers"] - # No extra domain registered assert "docker.acme.com" not in cfg["credHelpers"] - def test_missing_org_skips_discovery_install_succeeds(self, tmp_path, monkeypatch): - """discover=True with org=None skips discovery; default host is still registered.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) - + elif scenario in ("missing_org", "missing_api_key"): called = [] def _should_not_be_called(*_a, **_kw): @@ -759,598 +373,289 @@ def _should_not_be_called(*_a, **_kw): return [] monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called) - installer = DockerInstaller() - installer.install( - bin_dir=str(bin_dir), - discover=True, - org=None, - api_key="k_test", - ) - - # Discovery must not have run - assert not called, "get_format_domains must not be called when org is absent" - # Default host must still be registered - cfg = json.loads((docker_dir / "config.json").read_text()) - assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" - - def test_missing_api_key_skips_discovery_install_succeeds( - self, tmp_path, monkeypatch - ): - """discover=True with api_key=None skips discovery; default host is still registered.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) - - called = [] - - def _should_not_be_called(*_a, **_kw): - called.append(True) - return [] - - monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called) - - installer = DockerInstaller() - installer.install( - bin_dir=str(bin_dir), - discover=True, - org="acme", - api_key=None, - ) - - # Discovery must not have run + org = None if scenario == "missing_org" else "acme" + api_key = "k_test" if scenario == "missing_org" else None + installer.install(bin_dir=str(bin_dir), discover=True, org=org, api_key=api_key) assert ( not called - ), "get_format_domains must not be called when api_key is absent" - # Default host must still be registered + ), "get_format_domains must not be called when org/api_key absent" cfg = json.loads((docker_dir / "config.json").read_text()) assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" - def test_discovery_failure_is_graceful(self, tmp_path, monkeypatch): - """A discovery error (e.g. network down) must not abort install; returns WARNING.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) + else: # discovery_raises — graceful failure guard def _raise(*_a, **_kw): raise RuntimeError("network down") monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _raise) - installer = DockerInstaller() # Must NOT raise actions = installer.install( - bin_dir=str(bin_dir), - discover=True, - org="acme", - api_key="k_test", + bin_dir=str(bin_dir), discover=True, org="acme", api_key="k_test" ) - - # Default host still registered cfg = json.loads((docker_dir / "config.json").read_text()) assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" - - # Launcher created assert (bin_dir / "docker-credential-cloudsmith").exists() - - # WARNING action present warning_actions = [a for a in actions if a.startswith("WARNING")] assert warning_actions, f"Expected a WARNING action, got: {actions}" assert any("network down" in a for a in warning_actions) - def test_discovery_returns_default_host_reports_zero_net_new( - self, tmp_path, monkeypatch - ): - """If discovery returns docker.cloudsmith.io (DEFAULT_HOST), credHelpers has - a single entry and the action message reports 0 net-new domains.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) - - monkeypatch.setattr( - _INSTALLER_GET_FORMAT_DOMAINS, - lambda *_a, **_kw: ["docker.cloudsmith.io"], - ) - - installer = DockerInstaller() - actions = installer.install( - bin_dir=str(bin_dir), - discover=True, - org="acme", - api_key="k_test", - ) - - cfg = json.loads((docker_dir / "config.json").read_text()) - helpers = cfg["credHelpers"] - # Only one entry for the default host - assert list(helpers.keys()).count("docker.cloudsmith.io") == 1 - assert helpers["docker.cloudsmith.io"] == "cloudsmith" - # Discovered action must report 0 net-new - discovered_actions = [a for a in actions if "discovered" in a] - assert discovered_actions, f"Expected a discovered action, got: {actions}" - assert any( - "0" in a for a in discovered_actions - ), f"Expected 0 net-new in discovered action, got: {discovered_actions}" - - def test_dedup_prevents_duplicate_hosts(self, tmp_path, monkeypatch): - """If discovery returns a host already in --domain, it is not duplicated.""" - docker_dir = tmp_path / ".docker" - monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) - - # Both explicit domain and discovered return the same host - monkeypatch.setattr( - _INSTALLER_GET_FORMAT_DOMAINS, - lambda *_a, **_kw: ["docker.acme.com"], - ) - - installer = DockerInstaller() - installer.install( - bin_dir=str(bin_dir), - domains=("docker.acme.com",), - discover=True, - org="acme", - api_key="k_test", - ) - - cfg = json.loads((docker_dir / "config.json").read_text()) - # credHelpers is a dict so duplicates are impossible at the JSON level, - # but we verify the host appears exactly once (dict semantics). - helpers = cfg["credHelpers"] - assert helpers.get("docker.acme.com") == "cloudsmith" - # --------------------------------------------------------------------------- -# --refresh bypasses cache (unit test on get_custom_domains) +# 11. --refresh # --------------------------------------------------------------------------- -class TestRefreshBypassesCache: - """Verify that refresh=True skips the on-disk cache in get_custom_domains.""" - - @pytest.fixture(autouse=True) - def _redirect_cache(self, tmp_path, monkeypatch): - monkeypatch.setattr( - "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", - lambda: str(tmp_path), - ) - - def test_refresh_false_uses_cache(self, tmp_path): - """refresh=False (default) returns cached domains without hitting the API.""" - import time - - from ....credential_helpers.custom_domains import ( - CustomDomain, - get_cache_path, - get_custom_domains, - write_cache, - ) - - cache_path = get_cache_path("acme") - cached_domain = CustomDomain( - host="docker.acme.com", backend_kind=6, enabled=True, validated=True - ) - write_cache(cache_path, [cached_domain]) - # Touch the mtime to make the cache look fresh - os.utime(cache_path, (time.time(), time.time())) - - api_called = [] - - def _boom(*_a, **_kw): - api_called.append(True) - raise AssertionError("API should not be called when cache is valid") - - with patch( - "cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains", - _boom, - ): - result = get_custom_domains("acme", api_key="k", refresh=False) - - assert not api_called - assert result == [cached_domain] - - def test_refresh_true_bypasses_cache(self, tmp_path): - """refresh=True fetches from the API even when a valid cache exists.""" - import time - - from ....credential_helpers.custom_domains import ( - CustomDomain, - get_cache_path, - get_custom_domains, - write_cache, - ) - - cache_path = get_cache_path("acme") - stale_domain = CustomDomain( - host="old.acme.com", backend_kind=6, enabled=True, validated=True - ) - write_cache(cache_path, [stale_domain]) - os.utime(cache_path, (time.time(), time.time())) - - fresh_domain = CustomDomain( - host="new.acme.com", backend_kind=6, enabled=True, validated=True - ) - - def _fake_list(*_a, **_kw): - return [ - { - "host": "new.acme.com", - "backend_kind": 6, - "enabled": True, - "validated": True, - } - ] - - with patch( - "cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains", - _fake_list, - ): - result = get_custom_domains("acme", api_key="k", refresh=True) +@pytest.mark.parametrize("refresh", [False, True]) +def test_refresh_flag(tmp_path, monkeypatch, refresh): + """refresh=False uses the on-disk cache; refresh=True bypasses it and hits the API.""" + import time + + from ....credential_helpers.custom_domains import ( + CustomDomain, + get_cache_path, + get_custom_domains, + write_cache, + ) + + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.custom_domains.get_default_config_path", + lambda: str(tmp_path), + ) + + cache_path = get_cache_path("acme") + cached_domain = CustomDomain( + host="docker.acme.com", backend_kind=6, enabled=True, validated=True + ) + write_cache(cache_path, [cached_domain]) + os.utime(cache_path, (time.time(), time.time())) + + fresh_domain = CustomDomain( + host="new.acme.com", backend_kind=6, enabled=True, validated=True + ) + api_calls = [] + + def _fake_list(*_a, **_kw): + api_calls.append(True) + return [ + { + "host": "new.acme.com", + "backend_kind": 6, + "enabled": True, + "validated": True, + } + ] + + with patch( + "cloudsmith_cli.credential_helpers.custom_domains.list_custom_domains", + _fake_list, + ): + result = get_custom_domains("acme", api_key="k", refresh=refresh) + if refresh: + # API must have been called + assert api_calls, "API must be called when refresh=True" assert result == [fresh_domain] + else: + # API must NOT have been called + assert ( + not api_calls + ), "API must not be called when refresh=False with valid cache" + assert result == [cached_domain] # --------------------------------------------------------------------------- -# manage CLI — new flags +# 12. manage CLI — unknown helper # --------------------------------------------------------------------------- -class TestManageCLINewFlags: - """Tests for --no-discover, --refresh, and --org on the install CLI command.""" +def test_manage_cli_unknown_helper_exits_nonzero(runner): + """install/uninstall with an unknown helper name exits non-zero with a clear error.""" + from ....cli.commands.credential_helper.manage import install_cmd, uninstall_cmd - def test_install_no_discover_dry_run_exits_0(self, runner, tmp_path, monkeypatch): - """install docker --no-discover --dry-run exits 0.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - - from ....cli.commands.credential_helper.manage import install_cmd - - result = runner.invoke( - install_cmd, - [ - "docker", - "--no-discover", - "--dry-run", - "--bin-dir", - str(tmp_path / "bin"), - ], - ) + for cmd in (install_cmd, uninstall_cmd): + result = runner.invoke(cmd, ["badhelper"]) + assert result.exit_code != 0 - assert result.exit_code == 0, result.output - assert "would" in result.output.lower() or "dry run" in result.output.lower() - def test_install_dry_run_with_stubbed_discovery_exits_0( - self, runner, tmp_path, monkeypatch - ): - """install docker --dry-run with get_format_domains stubbed exits 0.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) +# --------------------------------------------------------------------------- +# 13. manage CLI dry-run +# --------------------------------------------------------------------------- - # Stub discovery so no real network call is made even if org+key are present - monkeypatch.setattr( - _INSTALLER_GET_FORMAT_DOMAINS, - lambda *_a, **_kw: [], - ) - from ....cli.commands.credential_helper.manage import install_cmd +def test_manage_cli_dry_run_exits_0(runner, tmp_path, monkeypatch): + """install docker --no-discover --dry-run exits 0.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - result = runner.invoke( - install_cmd, - ["docker", "--dry-run", "--bin-dir", str(tmp_path / "bin")], - ) + from ....cli.commands.credential_helper.manage import install_cmd - assert result.exit_code == 0, result.output + result = runner.invoke( + install_cmd, + ["docker", "--no-discover", "--dry-run", "--bin-dir", str(tmp_path / "bin")], + ) - def test_install_no_discover_does_not_call_get_format_domains( - self, runner, tmp_path, monkeypatch - ): - """--no-discover prevents get_format_domains from being called.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - bin_dir = tmp_path / "bin" - monkeypatch.setenv("PATH", str(bin_dir)) + assert result.exit_code == 0, result.output + assert "would" in result.output.lower() or "dry run" in result.output.lower() - called = [] - def _should_not_be_called(*_a, **_kw): - called.append(True) - return [] +# --------------------------------------------------------------------------- +# 14. PATH warning +# --------------------------------------------------------------------------- - monkeypatch.setattr(_INSTALLER_GET_FORMAT_DOMAINS, _should_not_be_called) - from ....cli.commands.credential_helper.manage import install_cmd +def test_path_warning_when_bin_dir_not_on_path(tmp_path, monkeypatch): + """install returns a WARNING action when target bin_dir is not on PATH.""" + docker_dir = tmp_path / ".docker" + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") - result = runner.invoke( - install_cmd, - ["docker", "--no-discover", "--bin-dir", str(bin_dir)], - ) + installer = DockerInstaller() + actions = installer.install(bin_dir=str(bin_dir)) - assert result.exit_code == 0, result.output - assert not called, "get_format_domains must not be called with --no-discover" + warning_actions = [a for a in actions if a.startswith("WARNING")] + assert warning_actions, f"Expected a WARNING action, got: {actions}" + assert any("PATH" in a for a in warning_actions) # --------------------------------------------------------------------------- -# -F / --output-format tests +# 15. Unwritable dir → clean ClickException (no raw traceback) # --------------------------------------------------------------------------- -class TestOutputFormat: - """Tests for -F json / -F pretty_json on install, uninstall, and list.""" - - # ------------------------------------------------------------------ - # install - # ------------------------------------------------------------------ - - def test_install_json_exits_0_and_parses(self, runner, tmp_path, monkeypatch): - """-F json: install exits 0 and stdout is valid JSON with expected shape.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - - from ....cli.commands.credential_helper.manage import install_cmd - - result = runner.invoke( - install_cmd, - [ - "docker", - "--dry-run", - "--no-discover", - "--bin-dir", - str(tmp_path / "bin"), - "-F", - "json", - ], - catch_exceptions=False, - ) +@pytest.mark.skipif( + os.name != "posix" or (hasattr(os, "geteuid") and os.geteuid() == 0), + reason="permission test only meaningful on POSIX as non-root", +) +def test_unwritable_bin_dir_gives_click_exception(runner, tmp_path, monkeypatch): + """install with an unwritable --bin-dir exits non-zero as ClickException/SystemExit, not raw OSError.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - assert result.exit_code == 0, result.output - parsed = json.loads(result.output) - data = parsed["data"] - assert data["helper"] == "docker" - assert data["dry_run"] is True - assert isinstance(data["actions"], list) - assert isinstance(data["warnings"], list) + from ....cli.commands.credential_helper.manage import install_cmd - def test_install_pretty_json_exits_0_and_parses( - self, runner, tmp_path, monkeypatch - ): - """-F pretty_json: install exits 0 and stdout is valid JSON.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + ro_dir = tmp_path / "readonly" + ro_dir.mkdir() + ro_dir.chmod(0o500) - from ....cli.commands.credential_helper.manage import install_cmd + try: + result = runner.invoke(install_cmd, ["docker", "--bin-dir", str(ro_dir)]) + finally: + ro_dir.chmod(0o700) - result = runner.invoke( - install_cmd, - [ - "docker", - "--dry-run", - "--no-discover", - "--bin-dir", - str(tmp_path / "bin"), - "-F", - "pretty_json", - ], - catch_exceptions=False, - ) + assert result.exit_code != 0 + assert not isinstance( + result.exception, OSError + ), f"Raw OSError escaped: {result.exception}" - assert result.exit_code == 0, result.output - parsed = json.loads(result.output) - assert "data" in parsed - def test_install_default_pretty_shows_human_text( - self, runner, tmp_path, monkeypatch - ): - """Default (no -F): install exits 0 and human-readable text appears.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) +# --------------------------------------------------------------------------- +# 16. -F output format +# --------------------------------------------------------------------------- - from ....cli.commands.credential_helper.manage import install_cmd - result = runner.invoke( - install_cmd, - [ - "docker", - "--dry-run", - "--no-discover", - "--bin-dir", - str(tmp_path / "bin"), - ], - catch_exceptions=False, - ) +_STUB_STATUS = { + "launcher": "/some/bin/docker-credential-cloudsmith", + "hosts": ["docker.cloudsmith.io"], +} - assert result.exit_code == 0, result.output - assert "dry run" in result.output.lower() or "would" in result.output.lower() - def test_install_json_stdout_is_pure_json(self, runner, tmp_path, monkeypatch): - """-F json: stdout contains no leading human text before the JSON.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) +def _stub_status_fn(_self): + return _STUB_STATUS - from ....cli.commands.credential_helper.manage import install_cmd - result = runner.invoke( - install_cmd, +@pytest.mark.parametrize( + "cmd_name,cli_args,expected_helper,expect_dry_run_key", + [ + # install dry-run with -F json + ( + "install_cmd", [ "docker", "--dry-run", "--no-discover", "--bin-dir", - str(tmp_path / "bin"), + "{bin_dir}", "-F", "json", ], - catch_exceptions=False, - ) + "docker", + True, + ), + # uninstall dry-run with -F json + ( + "uninstall_cmd", + ["docker", "--dry-run", "-F", "json"], + "docker", + True, + ), + # list with -F json + ( + "list_cmd", + ["-F", "json"], + "docker", + False, + ), + ], +) +def test_output_format_json( + runner, + tmp_path, + monkeypatch, + cmd_name, + cli_args, + expected_helper, + expect_dry_run_key, +): + """-F json produces valid parseable JSON with expected top-level data shape. + + Retained guard: list -F json serialises a launcher path (str), not a Path object. + """ + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + monkeypatch.setattr(DockerInstaller, "status", _stub_status_fn) - assert result.exit_code == 0, result.output - # Must parse cleanly from position 0 — no leading prose - json.loads(result.output) + from ....cli.commands.credential_helper import manage as manage_mod - # ------------------------------------------------------------------ - # uninstall - # ------------------------------------------------------------------ + cmd = getattr(manage_mod, cmd_name) - def test_uninstall_json_exits_0_and_parses(self, runner, tmp_path, monkeypatch): - """-F json: uninstall exits 0 and stdout is valid JSON with expected shape.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) + # Replace {bin_dir} placeholder in args + resolved_args = [a.replace("{bin_dir}", str(tmp_path / "bin")) for a in cli_args] - from ....cli.commands.credential_helper.manage import uninstall_cmd + result = runner.invoke(cmd, resolved_args, catch_exceptions=False) - result = runner.invoke( - uninstall_cmd, - [ - "docker", - "--dry-run", - "-F", - "json", - ], - catch_exceptions=False, - ) + assert result.exit_code == 0, result.output + # Pure JSON on stdout (no human text leaking before the JSON) + assert result.output.strip().startswith( + "{" + ), f"Output does not start with {{: {result.output[:100]!r}" + parsed = json.loads(result.output) + data = parsed["data"] - assert result.exit_code == 0, result.output - parsed = json.loads(result.output) - data = parsed["data"] - assert data["helper"] == "docker" - assert data["dry_run"] is True + if cmd_name == "list_cmd": + assert isinstance(data, list) + entry = next(e for e in data if e["helper"] == expected_helper) + assert "launcher" in entry + assert entry["launcher"] == "/some/bin/docker-credential-cloudsmith" + assert "hosts" in entry + else: + assert data["helper"] == expected_helper assert isinstance(data["actions"], list) assert isinstance(data["warnings"], list) + if expect_dry_run_key: + assert data["dry_run"] is True - def test_uninstall_pretty_json_exits_0_and_parses( - self, runner, tmp_path, monkeypatch - ): - """-F pretty_json: uninstall exits 0 and stdout is valid JSON.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - - from ....cli.commands.credential_helper.manage import uninstall_cmd - - result = runner.invoke( - uninstall_cmd, - [ - "docker", - "--dry-run", - "-F", - "pretty_json", - ], - catch_exceptions=False, - ) - - assert result.exit_code == 0, result.output - parsed = json.loads(result.output) - assert "data" in parsed - - def test_uninstall_default_pretty_shows_human_text( - self, runner, tmp_path, monkeypatch - ): - """Default (no -F): uninstall exits 0 and human-readable text appears.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - - from ....cli.commands.credential_helper.manage import uninstall_cmd - - result = runner.invoke( - uninstall_cmd, - ["docker", "--dry-run"], - catch_exceptions=False, - ) - - assert result.exit_code == 0, result.output - # dry-run with nothing installed produces output like "nothing to remove" - assert len(result.output) > 0 - - # ------------------------------------------------------------------ - # list - # ------------------------------------------------------------------ - - # Deterministic status fixture used by all list tests — includes a launcher - # path so that the "launcher present" JSON-serialisation path is exercised. - _STUB_STATUS = { - "launcher": "/some/bin/docker-credential-cloudsmith", - "hosts": ["docker.cloudsmith.io"], - } - - # Deterministic status return value used by all list tests — includes a - # launcher path so the "launcher present" JSON-serialisation path is covered. - _STUB_STATUS = { - "launcher": "/some/bin/docker-credential-cloudsmith", - "hosts": ["docker.cloudsmith.io"], - } - - @staticmethod - def _stub_status(_self): - return { - "launcher": "/some/bin/docker-credential-cloudsmith", - "hosts": ["docker.cloudsmith.io"], - } - - def test_list_json_exits_0_and_parses(self, runner, tmp_path, monkeypatch): - """-F json: list exits 0 and stdout is valid JSON with expected shape. - - DockerInstaller.status is patched to return a deterministic dict WITH a - launcher path present so this test covers the previously-failing - serialisation of pathlib.Path values. - """ - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - monkeypatch.setattr(DockerInstaller, "status", self._stub_status) - - from ....cli.commands.credential_helper.manage import list_cmd - - result = runner.invoke(list_cmd, ["-F", "json"], catch_exceptions=False) - - assert result.exit_code == 0, result.output - parsed = json.loads(result.output) - data = parsed["data"] - assert isinstance(data, list) - assert len(data) >= 1 - docker_entry = next(e for e in data if e["helper"] == "docker") - assert "launcher" in docker_entry - assert docker_entry["launcher"] == "/some/bin/docker-credential-cloudsmith" - assert "hosts" in docker_entry - assert docker_entry["hosts"] == ["docker.cloudsmith.io"] - assert isinstance(docker_entry["hosts"], list) - - def test_list_pretty_json_exits_0_and_parses(self, runner, tmp_path, monkeypatch): - """-F pretty_json: list exits 0 and stdout is valid JSON. - - Launcher present in patched status ensures Path serialisation is covered. - """ - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - monkeypatch.setattr(DockerInstaller, "status", self._stub_status) - - from ....cli.commands.credential_helper.manage import list_cmd - - result = runner.invoke(list_cmd, ["-F", "pretty_json"], catch_exceptions=False) - - assert result.exit_code == 0, result.output - parsed = json.loads(result.output) - assert "data" in parsed - - def test_list_default_pretty_shows_docker(self, runner, tmp_path, monkeypatch): - """Default (no -F): list exits 0 and docker entry with launcher path appears in output.""" - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - monkeypatch.setattr(DockerInstaller, "status", self._stub_status) - - from ....cli.commands.credential_helper.manage import list_cmd - - result = runner.invoke(list_cmd, [], catch_exceptions=False) - - assert result.exit_code == 0, result.output - assert "docker" in result.output - assert "/some/bin/docker-credential-cloudsmith" in result.output - - def test_list_json_stdout_is_pure_json(self, runner, tmp_path, monkeypatch): - """-F json: stdout starts with '{' — no human text leaks before JSON. - Regression guard: previously failed with "Failed to convert to JSON: - Type not serializable" when a launcher was - present. Patching status with a launcher-present dict reproduces that - environment deterministically. - """ - monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - monkeypatch.setattr(DockerInstaller, "status", self._stub_status) +def test_output_format_default_shows_human_text(runner, tmp_path, monkeypatch): + """Default (no -F) install dry-run shows human-readable text, not raw JSON.""" + monkeypatch.setenv("DOCKER_CONFIG", str(tmp_path / ".docker")) - from ....cli.commands.credential_helper.manage import list_cmd + from ....cli.commands.credential_helper.manage import install_cmd - result = runner.invoke(list_cmd, ["-F", "json"], catch_exceptions=False) + result = runner.invoke( + install_cmd, + ["docker", "--dry-run", "--no-discover", "--bin-dir", str(tmp_path / "bin")], + catch_exceptions=False, + ) - assert result.exit_code == 0, result.output - # Strip trailing whitespace only; the first non-whitespace char must open JSON - assert result.output.strip().startswith("{") - data = json.loads(result.output) - docker_entry = next(e for e in data["data"] if e["helper"] == "docker") - assert docker_entry["launcher"] == "/some/bin/docker-credential-cloudsmith" + assert result.exit_code == 0, result.output + assert "dry run" in result.output.lower() or "would" in result.output.lower() From 44cb0e52153eba1e5892e7367b1fdb00af929ec2 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 16:58:33 +0100 Subject: [PATCH 19/21] fix(credential-helper): make launcher tests Python 3.11-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher/resolve tests monkeypatched os.name="nt" on a posix host to exercise the Windows code paths. On Python < 3.12 that makes pathlib.Path build a WindowsPath, which raises NotImplementedError — crashing the whole pytest session in CI (3.11) during failure-report/cache-write. It passed locally only because 3.12+ rewrote pathlib to tolerate it. Extract the platform-specific logic (launcher filename, script body, user bin dir) into pure helpers parameterised on `windows: bool`, and test those directly instead of faking os.name. Public API and return types unchanged; behaviour on real Windows is identical. Co-Authored-By: Claude --- .../test_credential_helper_install.py | 120 ++++++++++-------- .../credential_helpers/launchers.py | 68 +++++++--- 2 files changed, 113 insertions(+), 75 deletions(-) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index af50a599..d7c696ab 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -14,6 +14,9 @@ from ....credential_helpers.docker.installer import DockerInstaller from ....credential_helpers.launchers import ( + _launcher_content, + _launcher_filename, + _user_bin_dir, is_on_path, remove_launcher, resolve_bin_dir, @@ -32,44 +35,59 @@ def runner(): # --------------------------------------------------------------------------- -# 1. write_launcher — unix and windows +# 1. launcher filename + content (pure, platform-parameterised) # --------------------------------------------------------------------------- -@pytest.mark.parametrize("platform", ["unix", "windows"]) -def test_write_launcher(tmp_path, monkeypatch, platform): - """write_launcher produces exact content and correct mode for unix and windows. +@pytest.mark.parametrize( + "windows,expected_name,expected_content", + [ + ( + False, + "docker-credential-cloudsmith", + '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n', + ), + ( + True, + "docker-credential-cloudsmith.cmd", + "@echo off\r\ncloudsmith credential-helper docker %*\r\n", + ), + ], +) +def test_launcher_filename_and_content(windows, expected_name, expected_content): + """Per-platform launcher name + body — guards the exact Windows .cmd bytes. - Retained guard: exact Windows .cmd content (CRLF, @echo off, %*). + Parameterised on ``windows`` rather than patching ``os.name`` so no + ``pathlib.Path`` is built under a faked platform (which raises + ``NotImplementedError`` on Python < 3.12). """ - import cloudsmith_cli.credential_helpers.launchers as _launchers_mod - - if platform == "windows": - monkeypatch.setattr(_launchers_mod.os, "name", "nt") - dest = write_launcher( - tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", - ) - assert str(dest).endswith(".cmd") - raw = Path(str(dest)).read_bytes() - assert raw == b"@echo off\r\ncloudsmith credential-helper docker %*\r\n" - else: - dest = write_launcher( - tmp_path, - "docker-credential-cloudsmith", - "cloudsmith credential-helper docker", - ) - expected = '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n' - assert dest.read_text(encoding="utf-8") == expected - assert stat.S_IMODE(dest.stat().st_mode) == 0o755 + assert ( + _launcher_filename("docker-credential-cloudsmith", windows=windows) + == expected_name + ) + assert ( + _launcher_content("cloudsmith credential-helper docker", windows=windows) + == expected_content + ) # --------------------------------------------------------------------------- -# 2. remove_launcher +# 2. write_launcher / remove_launcher — end-to-end on the host platform # --------------------------------------------------------------------------- +def test_write_launcher_writes_executable_script(tmp_path): + """write_launcher writes the shim with content + 0o755 on the host platform.""" + dest = write_launcher( + tmp_path, + "docker-credential-cloudsmith", + "cloudsmith credential-helper docker", + ) + expected = '#!/bin/sh\nexec cloudsmith credential-helper docker "$@"\n' + assert dest.read_text(encoding="utf-8") == expected + assert stat.S_IMODE(dest.stat().st_mode) == 0o755 + + def test_remove_launcher(tmp_path): """remove_launcher returns True + file gone when present, False when absent.""" write_launcher( @@ -85,38 +103,32 @@ def test_remove_launcher(tmp_path): # --------------------------------------------------------------------------- -# 3. resolve_bin_dir +# 3. resolve_bin_dir + user-bin (no os.name faking) # --------------------------------------------------------------------------- +def test_resolve_bin_dir_override(tmp_path): + """An explicit override is returned verbatim.""" + assert resolve_bin_dir(str(tmp_path)) == tmp_path + + @pytest.mark.parametrize( - "scenario", ["override", "user_bin_fallback", "windows_user_bin"] + "windows,expected_suffix", + [ + (False, (".local", "bin")), + (True, ("Cloudsmith", "bin")), + ], ) -def test_resolve_bin_dir(tmp_path, monkeypatch, scenario): - """resolve_bin_dir respects an override, falls back to user-bin on posix/windows.""" - import cloudsmith_cli.credential_helpers.launchers as _launchers_mod - - if scenario == "override": - result = resolve_bin_dir(str(tmp_path)) - assert result == tmp_path - - elif scenario == "user_bin_fallback": - monkeypatch.setattr(_launchers_mod.shutil, "which", lambda _name: None) - monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) - monkeypatch.setattr(_launchers_mod.os, "name", "posix") - monkeypatch.setattr(_launchers_mod.os, "access", lambda _path, _mode: False) - result = resolve_bin_dir() - assert result == tmp_path / ".local" / "bin" - - else: # windows_user_bin - monkeypatch.setattr(_launchers_mod.shutil, "which", lambda _name: None) - monkeypatch.setattr(_launchers_mod.os, "name", "nt") - monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) - monkeypatch.setattr(_launchers_mod.os, "access", lambda _path, _mode: False) - result = resolve_bin_dir() - result_str = str(result).replace("\\", "/") - expected_str = str(tmp_path / "Cloudsmith" / "bin").replace("\\", "/") - assert result_str == expected_str +def test_user_bin_dir(tmp_path, monkeypatch, windows, expected_suffix): + """_user_bin_dir returns the per-platform user bin location. + + Parameterised on ``windows`` (not ``os.name``) to avoid building a + ``WindowsPath`` on a posix host. + """ + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + result = _user_bin_dir(windows) + assert result.parts[-2:] == expected_suffix # --------------------------------------------------------------------------- diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index 3c49a007..cb0750ea 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -4,6 +4,11 @@ Creates a thin shell script (Unix) or .cmd batch file (Windows) named ``docker-credential-cloudsmith`` (or similar) that forwards every call to the single ``cloudsmith`` binary already installed on the user's PATH. + +The platform-specific bits (file name, script body, user bin directory) live in +small pure helpers parameterised on ``windows`` so they can be unit-tested +without monkeypatching ``os.name`` — faking ``os.name`` on a posix host makes +``pathlib.Path`` raise ``NotImplementedError`` on Python < 3.12. """ from __future__ import annotations @@ -14,6 +19,41 @@ from pathlib import Path +def _is_windows() -> bool: + """Return True when running on Windows.""" + return os.name == "nt" + + +def _launcher_filename(name: str, *, windows: bool) -> str: + """Return the launcher file name for the platform (``.cmd`` on Windows).""" + return f"{name}.cmd" if windows else name + + +def _launcher_content(target_cmd: str, *, windows: bool) -> str: + """Return the launcher script body for the platform. + + Windows uses a ``.cmd`` batch file (``@echo off`` keeps stdout clean for + Docker's credential JSON); Unix uses a ``#!/bin/sh`` script that ``exec``s + the target so signals and the exit code pass straight through. + """ + if windows: + return f"@echo off\r\n{target_cmd} %*\r\n" + return f'#!/bin/sh\nexec {target_cmd} "$@"\n' + + +def _user_bin_dir(windows: bool) -> Path: + """Return the user-local bin directory for the platform. + + Unix → ``~/.local/bin``; Windows → ``%LOCALAPPDATA%\\Cloudsmith\\bin`` + (falling back to the home directory when ``LOCALAPPDATA`` is unset). + """ + if windows: + localappdata = os.environ.get("LOCALAPPDATA") + base = Path(localappdata) if localappdata else Path.home() + return base / "Cloudsmith" / "bin" + return Path.home() / ".local" / "bin" + + def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: """Write a launcher script for *name* in *bin_dir* that execs *target_cmd*. @@ -32,15 +72,13 @@ def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: Path The path of the written file. """ + windows = _is_windows() bin_dir = Path(bin_dir) bin_dir.mkdir(parents=True, exist_ok=True) - if os.name == "nt": - dest = Path(os.path.join(str(bin_dir), f"{name}.cmd")) - dest.write_text(f"@echo off\r\n{target_cmd} %*\r\n", encoding="utf-8") - else: - dest = Path(os.path.join(str(bin_dir), name)) - dest.write_text(f'#!/bin/sh\nexec {target_cmd} "$@"\n', encoding="utf-8") + dest = bin_dir / _launcher_filename(name, windows=windows) + dest.write_text(_launcher_content(target_cmd, windows=windows), encoding="utf-8") + if not windows: dest.chmod(0o755) return dest @@ -61,12 +99,7 @@ def remove_launcher(bin_dir: Path, name: str) -> bool: bool ``True`` if a file was removed, ``False`` if no file was found. """ - bin_dir = Path(bin_dir) - - if os.name == "nt": - target = Path(os.path.join(str(bin_dir), f"{name}.cmd")) - else: - target = Path(os.path.join(str(bin_dir), name)) + target = Path(bin_dir) / _launcher_filename(name, windows=_is_windows()) if target.exists(): target.unlink() @@ -82,9 +115,7 @@ def resolve_bin_dir(override: str | None = None) -> Path: 1. *override* → ``Path(override)``. 2. The directory of the running ``cloudsmith`` executable — if that directory is writable. - 3. The user-local bin directory: - - Unix: ``~/.local/bin`` - - Windows: ``%LOCALAPPDATA%\\Cloudsmith\\bin`` + 3. The user-local bin directory (see :func:`_user_bin_dir`). The chosen directory is **not** created here; that happens when the launcher is written via :func:`write_launcher`. @@ -113,12 +144,7 @@ def resolve_bin_dir(override: str | None = None) -> Path: return candidate # Option 3: user-local bin - if os.name == "nt": - localappdata = os.environ.get("LOCALAPPDATA") - base = Path(localappdata) if localappdata else Path.home() - return Path(os.path.join(str(base), "Cloudsmith", "bin")) - - return Path(os.path.join(str(Path.home()), ".local", "bin")) + return _user_bin_dir(_is_windows()) def is_on_path(directory: Path) -> bool: From c52783541f4b4c60a38a83a7aa48d3502feb7713 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 17:21:39 +0100 Subject: [PATCH 20/21] fix(credential-helper): harden config merge + exact launcher bytes (copilot review) Coerce non-dict credHelpers to {} on install (Fix 1), guard non-dict credHelpers as a no-op on uninstall (Fix 2), pass newline="" to write_text to prevent \r\r\n on Windows (Fix 3), and require W_OK|X_OK when selecting candidate bin dir (Fix 4). Add tests 17 and 18 covering the two malformed-credHelpers edge cases. Co-Authored-By: Claude --- .../test_credential_helper_install.py | 45 +++++++++++++++++++ .../credential_helpers/docker/installer.py | 12 +++-- .../credential_helpers/launchers.py | 6 ++- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py index d7c696ab..60c8037b 100644 --- a/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py @@ -671,3 +671,48 @@ def test_output_format_default_shows_human_text(runner, tmp_path, monkeypatch): assert result.exit_code == 0, result.output assert "dry run" in result.output.lower() or "would" in result.output.lower() + + +# --------------------------------------------------------------------------- +# 17. Malformed credHelpers robustness +# --------------------------------------------------------------------------- + + +def test_install_coerces_malformed_cred_helpers(tmp_path, monkeypatch): + """install coerces a non-dict credHelpers (list) rather than raising TypeError.""" + docker_dir = tmp_path / ".docker" + docker_dir.mkdir(parents=True) + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (list instead of dict) + (docker_dir / "config.json").write_text( + json.dumps({"credHelpers": ["not", "a", "dict"]}), + encoding="utf-8", + ) + + installer = DockerInstaller() + # Must not raise + installer.install(bin_dir=str(bin_dir), discover=False) + + cfg = json.loads((docker_dir / "config.json").read_text(encoding="utf-8")) + assert isinstance(cfg["credHelpers"], dict) + assert cfg["credHelpers"]["docker.cloudsmith.io"] == "cloudsmith" + + +def test_uninstall_tolerates_malformed_cred_helpers(tmp_path, monkeypatch): + """uninstall treats a non-dict credHelpers (string) as a no-op rather than raising.""" + docker_dir = tmp_path / ".docker" + docker_dir.mkdir(parents=True) + monkeypatch.setenv("DOCKER_CONFIG", str(docker_dir)) + bin_dir = tmp_path / "bin" + + # Seed config with a malformed credHelpers value (string instead of dict) + (docker_dir / "config.json").write_text( + json.dumps({"credHelpers": "garbage"}), + encoding="utf-8", + ) + + installer = DockerInstaller() + # Must not raise + installer.uninstall(bin_dir=str(bin_dir)) diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index e1df0f51..2609ed28 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -162,9 +162,11 @@ def install( hosts = deduped def mutate(config: dict) -> None: - config.setdefault("credHelpers", {}) + helpers = config.get("credHelpers") + if not isinstance(helpers, dict): + helpers = config["credHelpers"] = {} for host in hosts: - config["credHelpers"][host] = self.HELPER_VALUE + helpers[host] = self.HELPER_VALUE if dry_run: if os.name == "nt": @@ -236,11 +238,13 @@ def uninstall( config_path = _docker_config_path() def mutate(config: dict) -> None: - helpers = config.get("credHelpers", {}) + helpers = config.get("credHelpers") + if not isinstance(helpers, dict): + return removed = [k for k, v in helpers.items() if v == self.HELPER_VALUE] for key in removed: del helpers[key] - if removed and not config["credHelpers"]: + if removed and not helpers: del config["credHelpers"] actions: list[str] = [] diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index cb0750ea..acf978ef 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -77,7 +77,9 @@ def write_launcher(bin_dir: Path, name: str, target_cmd: str) -> Path: bin_dir.mkdir(parents=True, exist_ok=True) dest = bin_dir / _launcher_filename(name, windows=windows) - dest.write_text(_launcher_content(target_cmd, windows=windows), encoding="utf-8") + dest.write_text( + _launcher_content(target_cmd, windows=windows), encoding="utf-8", newline="" + ) if not windows: dest.chmod(0o755) @@ -140,7 +142,7 @@ def resolve_bin_dir(override: str | None = None) -> Path: else: candidate = Path(os.path.dirname(os.path.realpath(sys.argv[0]))) - if os.access(candidate, os.W_OK): + if os.access(candidate, os.W_OK | os.X_OK): return candidate # Option 3: user-local bin From c2ba143d07b872934788170ff79cb8f52decd6ae Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 8 Jun 2026 17:23:07 +0100 Subject: [PATCH 21/21] chore(credential-helper): add copyright headers to new modules (copilot review) Add missing '# Copyright 2026 Cloudsmith Ltd' header to five new source files flagged by Copilot for inconsistent licensing headers. Matches the convention established in cache_utils.py and backends.py exactly. Co-Authored-By: Claude --- cloudsmith_cli/cli/commands/credential_helper/__init__.py | 1 + cloudsmith_cli/cli/commands/credential_helper/docker.py | 1 + cloudsmith_cli/credential_helpers/__init__.py | 1 + cloudsmith_cli/credential_helpers/common.py | 1 + cloudsmith_cli/credential_helpers/docker/__init__.py | 1 + 5 files changed, 5 insertions(+) diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index bccbeb6d..93e5feae 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd """ Credential helper commands for Cloudsmith. diff --git a/cloudsmith_cli/cli/commands/credential_helper/docker.py b/cloudsmith_cli/cli/commands/credential_helper/docker.py index adc05202..2829fa14 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/docker.py +++ b/cloudsmith_cli/cli/commands/credential_helper/docker.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd """ Docker credential helper command. diff --git a/cloudsmith_cli/credential_helpers/__init__.py b/cloudsmith_cli/credential_helpers/__init__.py index 6e6715ce..de2fb44d 100644 --- a/cloudsmith_cli/credential_helpers/__init__.py +++ b/cloudsmith_cli/credential_helpers/__init__.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd """ Credential helpers for various package managers. diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index 9fb207eb..f7bcf85b 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd """ Shared utilities for credential helpers. diff --git a/cloudsmith_cli/credential_helpers/docker/__init__.py b/cloudsmith_cli/credential_helpers/docker/__init__.py index f5a865db..76414023 100644 --- a/cloudsmith_cli/credential_helpers/docker/__init__.py +++ b/cloudsmith_cli/credential_helpers/docker/__init__.py @@ -1,3 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd from .runtime import execute, get_credentials __all__ = ["execute", "get_credentials"]