diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index c1bf2db5..5e121c1b 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -9,6 +9,7 @@ import click from ..main import main +from .cargo import cargo as cargo_cmd from .docker import docker as docker_cmd from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd @@ -45,5 +46,6 @@ def credential_helper(): credential_helper.add_command(install_cmd, name="install") credential_helper.add_command(uninstall_cmd, name="uninstall") credential_helper.add_command(list_cmd, name="list") +credential_helper.add_command(cargo_cmd, name="cargo") main.add_command(credential_helper, name="credential-helper") diff --git a/cloudsmith_cli/cli/commands/credential_helper/cargo.py b/cloudsmith_cli/cli/commands/credential_helper/cargo.py new file mode 100644 index 00000000..917d3ced --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/cargo.py @@ -0,0 +1,68 @@ +# Copyright 2026 Cloudsmith Ltd +""" +Docker credential helper command. + +Implements the Docker credential helper protocol for Cloudsmith registries. + +See: https://github.com/docker/docker-credential-helpers +""" + +import sys + +import click + +from ....credential_helpers.cargo 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 cargo(opts, operation): + """ + Cargo credential helper for Cloudsmith registries. + + Reads a Docker registry server URL from stdin and returns credentials in + TOML format. Implements the full Docker credential helper protocol + (get/store/erase/list). + + Provides credentials for all Cloudsmith Docker registries: ``*.cloudsmith.io``, + ``*.cloudsmith.com``, and any custom domains configured for the organisation + (requires an organisation - ``--org``, CLOUDSMITH_ORG or ``org`` in + ``config.ini`` - and a valid API key/token). + + 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 + + # Called by Docker via launcher + $ echo "docker.cloudsmith.io" | docker-credential-cloudsmith get + + Environment variables: + CLOUDSMITH_API_KEY: API key for authentication (optional) + CLOUDSMITH_ORG: Organisation slug (required for custom domain support) + """ + exit_code, stdout, stderr = execute( + operation, + sys.stdin, + credential=opts.credential, + api_host=opts.api_host, + org=opts.org, + ) + + 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/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 2eaa94ef..b1d19fe8 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -14,6 +14,7 @@ from cloudsmith_cli.credential_helpers.generic import PartialInstallError from cloudsmith_cli.credential_helpers.pnpm.installer import PNPMInstaller +from cloudsmith_cli.credential_helpers.cargo.installer import CargoInstaller from ....credential_helpers.docker.installer import DockerInstaller from ... import utils @@ -31,6 +32,7 @@ _INSTALLERS: dict[str, type] = { "docker": DockerInstaller, "pnpm": PNPMInstaller, + "cargo": CargoInstaller, } diff --git a/cloudsmith_cli/cli/tests/test_exceptions.py b/cloudsmith_cli/cli/tests/test_exceptions.py index 588e479a..56455ffc 100644 --- a/cloudsmith_cli/cli/tests/test_exceptions.py +++ b/cloudsmith_cli/cli/tests/test_exceptions.py @@ -8,7 +8,6 @@ from cloudsmith_cli.core.api.exceptions import ApiException from cloudsmith_cli.core.credentials.models import CredentialResult - API_KEY_HINT = ( "This usually means your API key is invalid, expired, or lacks access to this " "resource - check your credentials and try again." diff --git a/cloudsmith_cli/cli/tests/test_push.py b/cloudsmith_cli/cli/tests/test_push.py index 1b6c3fe6..f5029705 100644 --- a/cloudsmith_cli/cli/tests/test_push.py +++ b/cloudsmith_cli/cli/tests/test_push.py @@ -1490,21 +1490,23 @@ def test_wait_for_package_sync_json_mode_prints_status_reason_on_failure(capsys) status_reason, ) - with patch( - "cloudsmith_cli.cli.commands.push.get_package_status", - return_value=failed_status, + with ( + patch( + "cloudsmith_cli.cli.commands.push.get_package_status", + return_value=failed_status, + ), + pytest.raises(click.exceptions.Exit) as exc_info, ): - with pytest.raises(click.exceptions.Exit) as exc_info: - wait_for_package_sync( - ctx=ctx, - opts=opts, - owner="bart-demo-org-terraform", - repo="eng-13978-cli-repro", - slug="eng-13978-repro-100-alpha4tgz", - wait_interval=1.0, - skip_errors=False, - attempts=1, - ) + wait_for_package_sync( + ctx=ctx, + opts=opts, + owner="bart-demo-org-terraform", + repo="eng-13978-cli-repro", + slug="eng-13978-repro-100-alpha4tgz", + wait_interval=1.0, + skip_errors=False, + attempts=1, + ) assert exc_info.value.exit_code == 1 captured = capsys.readouterr() diff --git a/cloudsmith_cli/core/cache_utils.py b/cloudsmith_cli/core/cache_utils.py index c08e9a95..e16237fe 100644 --- a/cloudsmith_cli/core/cache_utils.py +++ b/cloudsmith_cli/core/cache_utils.py @@ -7,7 +7,9 @@ import os import tempfile from collections.abc import Callable -from typing import Any +from typing import Any, Literal + +import toml def _atomic_write_text(dest: str, text: str, *, mode: int = 0o600) -> None: @@ -44,13 +46,14 @@ def atomic_write_json(path: str | os.PathLike, data: Any, *, mode: int = 0o600) _atomic_write_text(dest, json.dumps(data), mode=mode) -def merge_json_file( +def merge_config_file( path: str | os.PathLike, mutate: Callable[[dict], None], *, backup: bool = True, dry_run: bool = False, mode: int = 0o600, + format: Literal["json", "toml"] = "json", ) -> bool: """Read a JSON object file, apply *mutate* in place, and atomically write it back. @@ -69,6 +72,8 @@ def merge_json_file( **no** writes (no temp file, no ``.bak``, no replace). mode: File-permission bits for the written file (default ``0o600``). + format: + Format to read/write from Returns ------- @@ -110,13 +115,21 @@ def merge_json_file( # ------------------------------------------------------------------ data: dict = {} if existing_text: - try: - parsed = json.loads(existing_text) - if isinstance(parsed, dict): - data = parsed - except (json.JSONDecodeError, ValueError): - pass - + match format: + case "json": + try: + parsed = json.loads(existing_text) + if isinstance(parsed, dict): + data = parsed + except (json.JSONDecodeError, ValueError): + pass + case "toml": + try: + parsed = toml.loads(existing_text) + if isinstance(parsed, dict): + data = parsed + except (toml.TomlDecodeError, ValueError): + pass # ------------------------------------------------------------------ # 3. Mutate in place # ------------------------------------------------------------------ @@ -125,7 +138,11 @@ def merge_json_file( # ------------------------------------------------------------------ # 4. Stable serialisation + change detection # ------------------------------------------------------------------ - new_text = json.dumps(data, indent=2, ensure_ascii=False) + "\n" + new_text = ( + json.dumps(data, indent=2, ensure_ascii=False) + "\n" + if format == "json" + else toml.dumps(data) + ) if existing_text is not None: # Normalise existing content for comparison: if the file already has diff --git a/cloudsmith_cli/core/tests/test_aws_detector.py b/cloudsmith_cli/core/tests/test_aws_detector.py index 01dc2e9d..9cabd800 100644 --- a/cloudsmith_cli/core/tests/test_aws_detector.py +++ b/cloudsmith_cli/core/tests/test_aws_detector.py @@ -106,11 +106,11 @@ def _get_token_sts_region(env): session = boto3.Session( aws_access_key_id="test", aws_secret_access_key="test" ) - with mock.patch.object(detector, "_session", session): - with mock.patch.object( - session, "client", return_value=fake_sts - ) as client: - assert detector.get_token() == "jwt" + with ( + mock.patch.object(detector, "_session", session), + mock.patch.object(session, "client", return_value=fake_sts) as client, + ): + assert detector.get_token() == "jwt" (service_name,), call_kwargs = client.call_args assert service_name == "sts" return call_kwargs["region_name"] diff --git a/cloudsmith_cli/core/tests/test_cache_utils.py b/cloudsmith_cli/core/tests/test_cache_utils.py index fcc7eed4..d660d025 100644 --- a/cloudsmith_cli/core/tests/test_cache_utils.py +++ b/cloudsmith_cli/core/tests/test_cache_utils.py @@ -7,7 +7,7 @@ import os import stat -from cloudsmith_cli.core.cache_utils import atomic_write_json, merge_json_file +from cloudsmith_cli.core.cache_utils import atomic_write_json, merge_config_file # --------------------------------------------------------------------------- # Helpers @@ -58,7 +58,7 @@ def test_overwrites_existing(self, tmp_path): # --------------------------------------------------------------------------- -# merge_json_file +# merge_config_file # --------------------------------------------------------------------------- @@ -83,7 +83,7 @@ def test_existing_keys_preserved(self, tmp_path): with open(path, "w", encoding="utf-8") as f: json.dump(initial, f) - changed = merge_json_file( + changed = merge_config_file( path, _add_cred_helper("docker.cloudsmith.io"), ) @@ -104,7 +104,7 @@ def test_key_order_not_sorted(self, tmp_path): def noop(data: dict) -> None: data["new_key"] = 3 - merge_json_file(path, noop) + merge_config_file(path, noop) text = _read_text(path) assert text.index('"zzz"') < text.index('"aaa"'), "Key order must be preserved" @@ -114,7 +114,7 @@ class TestMergeJsonFileCreatesMissingFile: 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")) + changed = merge_config_file(path, _add_cred_helper("docker.cloudsmith.io")) assert changed is True assert os.path.exists(path) result = _read_json(path) @@ -123,18 +123,18 @@ def test_creates_file_when_missing(self, tmp_path): 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")) + merge_config_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")) + merge_config_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")) + merge_config_file(path, _add_cred_helper("x")) assert _perms(path) == 0o600 @@ -147,7 +147,7 @@ def test_backup_created_on_change(self, tmp_path): with open(path, "w", encoding="utf-8") as f: json.dump(initial, f) - merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + merge_config_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" @@ -155,7 +155,7 @@ def test_backup_created_on_change(self, tmp_path): def test_no_backup_when_file_missing(self, tmp_path): path = str(tmp_path / "config.json") - merge_json_file(path, _add_cred_helper("x")) + merge_config_file(path, _add_cred_helper("x")) assert not os.path.exists(path + ".bak") def test_no_backup_when_no_change(self, tmp_path): @@ -166,7 +166,7 @@ def test_no_backup_when_no_change(self, tmp_path): def noop_already_set(data: dict) -> None: data.setdefault("credHelpers", {})["x"] = "cloudsmith" - changed = merge_json_file(path, noop_already_set) + changed = merge_config_file(path, noop_already_set) assert changed is False assert not os.path.exists(path + ".bak") @@ -177,7 +177,7 @@ def test_backup_is_mode_0o600_regardless_of_source_perms(self, tmp_path): json.dump({"auths": {}}, f) os.chmod(path, 0o644) - merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + merge_config_file(path, _add_cred_helper("docker.cloudsmith.io")) bak_path = path + ".bak" assert os.path.exists(bak_path), ".bak must be created" @@ -193,10 +193,10 @@ 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) + first = merge_config_file(path, mutate) assert first is True - second = merge_json_file(path, mutate) + second = merge_config_file(path, mutate) assert second is False def test_idempotent_no_overwrite_bak(self, tmp_path): @@ -206,12 +206,12 @@ def test_idempotent_no_overwrite_bak(self, tmp_path): json.dump(initial, f) mutate = _add_cred_helper("docker.cloudsmith.io") - merge_json_file(path, mutate) # first: changes file, writes .bak + merge_config_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 + merge_config_file(path, mutate) # second: no change bak_mtime_after_second = os.path.getmtime(bak_path) assert bak_mtime_after_first == bak_mtime_after_second, ( @@ -227,7 +227,7 @@ def test_dry_run_returns_true_when_would_change(self, tmp_path): with open(path, "w", encoding="utf-8") as f: json.dump({}, f) - result = merge_json_file(path, _add_cred_helper("x"), dry_run=True) + result = merge_config_file(path, _add_cred_helper("x"), dry_run=True) assert result is True def test_dry_run_file_unchanged(self, tmp_path): @@ -238,7 +238,7 @@ def test_dry_run_file_unchanged(self, tmp_path): 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) + merge_config_file(path, _add_cred_helper("x"), dry_run=True) assert _read_text(path) == original_text, "dry_run must not modify the file" @@ -247,7 +247,7 @@ def test_dry_run_no_bak_created(self, tmp_path): 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) + merge_config_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): @@ -259,12 +259,12 @@ def test_dry_run_returns_false_when_no_change(self, tmp_path): def already_set(data: dict) -> None: data.setdefault("credHelpers", {})["x"] = "cloudsmith" - result = merge_json_file(path, already_set, dry_run=True) + result = merge_config_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) + result = merge_config_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)) @@ -278,7 +278,7 @@ def test_malformed_json_treated_as_empty(self, tmp_path): with open(path, "w", encoding="utf-8") as f: f.write("not json") - changed = merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + changed = merge_config_file(path, _add_cred_helper("docker.cloudsmith.io")) assert changed is True result = _read_json(path) assert result == {"credHelpers": {"docker.cloudsmith.io": "cloudsmith"}} @@ -288,7 +288,7 @@ def test_empty_file_treated_as_empty_dict(self, tmp_path): with open(path, "w", encoding="utf-8"): pass # touch / create empty file - merge_json_file(path, _add_cred_helper("x")) + merge_config_file(path, _add_cred_helper("x")) result = _read_json(path) assert "credHelpers" in result @@ -297,7 +297,7 @@ def test_json_array_treated_as_empty_dict(self, tmp_path): with open(path, "w", encoding="utf-8") as f: json.dump([1, 2, 3], f) - merge_json_file(path, _add_cred_helper("x")) + merge_config_file(path, _add_cred_helper("x")) result = _read_json(path) assert isinstance(result, dict) assert "credHelpers" in result @@ -308,7 +308,7 @@ class TestMergeJsonFileStableSerialization: def test_output_format(self, tmp_path): path = str(tmp_path / "config.json") - merge_json_file(path, _add_cred_helper("docker.cloudsmith.io")) + merge_config_file(path, _add_cred_helper("docker.cloudsmith.io")) text = _read_text(path) expected = json.dumps( {"credHelpers": {"docker.cloudsmith.io": "cloudsmith"}}, @@ -319,7 +319,7 @@ def test_output_format(self, tmp_path): def test_trailing_newline(self, tmp_path): path = str(tmp_path / "config.json") - merge_json_file(path, _add_cred_helper("x")) + merge_config_file(path, _add_cred_helper("x")) text = _read_text(path) assert text.endswith("\n") @@ -330,7 +330,7 @@ def test_non_ascii_host_raw_utf8_not_escaped(self, tmp_path): mutate = _add_cred_helper(unicode_host) # First call: file is created (content changes → True) - first = merge_json_file(path, mutate) + first = merge_config_file(path, mutate) assert first is True # The written file must contain the raw Unicode character @@ -347,7 +347,7 @@ def test_non_ascii_host_raw_utf8_not_escaped(self, tmp_path): os.path.getmtime(bak_path) if os.path.exists(bak_path) else None ) - second = merge_json_file(path, mutate) + second = merge_config_file(path, mutate) assert second is False # .bak must not have been touched on the no-op call @@ -364,12 +364,12 @@ class TestMergeJsonFileReturnValue: 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")) + result = merge_config_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) + merge_config_file(path, mutate) + result = merge_config_file(path, mutate) assert result is False diff --git a/cloudsmith_cli/credential_helpers/cargo/__init__.py b/cloudsmith_cli/credential_helpers/cargo/__init__.py new file mode 100644 index 00000000..76414023 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/cargo/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2026 Cloudsmith Ltd +from .runtime import execute, get_credentials + +__all__ = ["execute", "get_credentials"] diff --git a/cloudsmith_cli/credential_helpers/cargo/installer.py b/cloudsmith_cli/credential_helpers/cargo/installer.py new file mode 100644 index 00000000..6973de81 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/cargo/installer.py @@ -0,0 +1,349 @@ +# 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 logging +import os +import sys +from pathlib import Path + +from ...core.cache_utils import merge_config_file +from ...core.credentials.models import CredentialResult +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 _cargo_config_path() -> Path: + """Return the path to the Cargo client configuration file. + + Respects the ``CARGO_HOME`` environment variable; otherwise returns + the platform default ``~/.cargo/credentials.toml``. + """ + cargo_home = os.environ.get("CARGO_HOME", Path.home() / ".cargo") + return Path(cargo_home) / "credentials.toml" + + +class CargoInstaller: + """Manages installation of the Cargo credential helper for Cloudsmith. + + This installer writes a ``docker-credential-cloudsmith`` launcher binary + and patches ``~/.cargo/credentials.toml`` to route the configured registry + hosts through the Cloudsmith credential helper. + + Usage:: + + installer = CargoInstaller() + actions = installer.install(domains=["my-registry.example.com"]) + for action in actions: + print(action) + """ + + LAUNCHER_NAME = "cargo-credential-cloudsmith" + TARGET_CMD = "cloudsmith credential-helper cargo" + HELPER_VALUE = "cloudsmith" + DEFAULT_HOST = "cargo.cloudsmith.io" + + name = "cargo" + summary = "Cargo credential helper for Cloudsmith registries" + + @classmethod + def _resolve_target_cmd(cls) -> str: + """Return the command the launcher forwards to. + + A pip/source install resolves the bare ``cloudsmith`` command via + ``PATH``. A frozen standalone binary (PyInstaller) is not guaranteed + to be on ``PATH`` under that name, so point the launcher at the + absolute executable instead — mirroring the frozen handling in + :func:`cloudsmith_cli.cli.commands.mcp._get_server_config`. The path + is quoted so a directory containing spaces still execs correctly. + """ + if getattr(sys, "frozen", False): + return f'"{sys.executable}" credential-helper cargo' + return cls.TARGET_CMD + + def install( + self, + *, + bin_dir: str | None = None, + domains: tuple[str, ...] = (), + discover: bool = True, + refresh: bool = False, + org: str | None = None, + credential: CredentialResult | None = None, + api_host: str | None = None, + dry_run: bool = False, + ) -> list[str]: + """Install the Docker credential helper. + + Writes the launcher binary and registers Cloudsmith registry hosts in + ``~/.cargo/credentials.toml``. + + 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 ``cargo.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. + credential: + Resolved credential used for custom-domain discovery. + api_host: + Cloudsmith API host URL override. + 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 = _cargo_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 dry_run: + # Discovery queries the API and refreshes the on-disk domain + # cache, neither of which a "no changes" preview may do. + actions.append("skipped custom-domain auto-discovery (dry run)") + elif org and credential and credential.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.CARGO, + credential=credential, + 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() + deduped: list[str] = [] + for h in hosts: + if h not in seen: + seen.add(h) + deduped.append(h) + hosts = deduped + + def mutate(config: dict) -> None: + helpers = config.get("credHelpers") + if not isinstance(helpers, dict): + helpers = config["credHelpers"] = {} + for host in hosts: + helpers[host] = self.HELPER_VALUE + + 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_config_file( + config_path, mutate, dry_run=True, format="toml" + ) + 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._resolve_target_cmd() + ) + actions.append(f"wrote launcher {launcher_path}") + + changed = merge_config_file(config_path, mutate, format="toml") + if changed: + for host in hosts: + actions.append( + f"set credHelpers[{host!r}]={self.HELPER_VALUE!r} in {config_path}" + ) + else: + actions.append(f"credentials.toml 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 Cargo can find cargo-credential-cloudsmith" + ) + + return actions + + def uninstall( + self, *, bin_dir: str | None = None, dry_run: bool = False + ) -> list[str]: + """Uninstall the Cargo credential helper. + + Removes the launcher binary and strips Cloudsmith-managed entries from + ``~/.cargo/credentials.toml``. + + 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. + + Returns + ------- + list[str] + Human-readable descriptions of actions taken (or planned). + """ + target_dir = resolve_bin_dir(bin_dir) + config_path = _cargo_config_path() + + def mutate(config: dict) -> None: + 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 helpers: + 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_config_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_config_file(config_path, mutate, format="toml") + 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 ``credentials.toml``'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 = _cargo_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": str(launcher_path) if launcher_path is not None else None, + "hosts": hosts, + } diff --git a/cloudsmith_cli/credential_helpers/cargo/runtime.py b/cloudsmith_cli/credential_helpers/cargo/runtime.py new file mode 100644 index 00000000..79c4f03e --- /dev/null +++ b/cloudsmith_cli/credential_helpers/cargo/runtime.py @@ -0,0 +1,125 @@ +# 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 ..backends import BackendKind +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, org=None): + """ + Get credentials for a Cloudsmith Cargo registry. + + Verifies the URL is a Cloudsmith registry (including custom domains) + and returns credentials if available. + + Args: + server_url: The Cargo registry server URL + credential: Pre-resolved CredentialResult from the provider chain + api_host: Cloudsmith API host URL + org: Organisation slug whose custom domains to match against + + 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, + credential=credential, + api_host=api_host, + backend_kind=BackendKind.DOCKER, + org=org, + ): + return None + + return {"Username": "token", "Secret": credential.api_key} + + +def _execute_get( + stdin, credential, api_host, org +) -> 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, org=org + ) + 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, org=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 + org: Organisation slug whose custom domains to match against + + 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, org) + + return ( + 1, + None, + ( + f"Error: Unknown operation '{operation}'. " + "Valid operations: get, store, erase, list" + ), + ) diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index 818812d8..23d977df 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -14,7 +14,7 @@ import sys from pathlib import Path -from ...core.cache_utils import merge_json_file +from ...core.cache_utils import merge_config_file from ...core.credentials.models import CredentialResult from ..backends import BackendKind from ..custom_domains import get_format_domains @@ -192,7 +192,7 @@ def mutate(config: dict) -> None: 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) + would_change = merge_config_file(config_path, mutate, dry_run=True) for host in hosts: if would_change: actions.append( @@ -212,7 +212,7 @@ def mutate(config: dict) -> None: ) actions.append(f"wrote launcher {launcher_path}") - changed = merge_json_file(config_path, mutate) + changed = merge_config_file(config_path, mutate) if changed: for host in hosts: actions.append( @@ -280,7 +280,7 @@ def mutate(config: dict) -> None: f"launcher not found at {launcher_path} (nothing to remove)" ) - would_change = merge_json_file(config_path, mutate, dry_run=True) + would_change = merge_config_file(config_path, mutate, dry_run=True) if would_change: actions.append( f"would remove credHelpers entries with value" @@ -297,7 +297,7 @@ def mutate(config: dict) -> None: else: actions.append(f"launcher not found at {launcher_path} (nothing to remove)") - changed = merge_json_file(config_path, mutate) + changed = merge_config_file(config_path, mutate) if changed: actions.append( f"removed credHelpers entries with value" diff --git a/pyproject.toml b/pyproject.toml index bab3fd9d..503355a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ dependencies = [ "rich>=13.0.0", "semver>=2.7.9", "urllib3>=2.5", + "toml>=0.10.2", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index dbdce796..fa822c10 100644 --- a/uv.lock +++ b/uv.lock @@ -540,6 +540,7 @@ dependencies = [ { name = "requests-toolbelt" }, { name = "rich" }, { name = "semver" }, + { name = "toml" }, { name = "urllib3" }, ] @@ -596,6 +597,7 @@ requires-dist = [ { name = "requests-toolbelt", specifier = ">=1.0.0" }, { name = "rich", specifier = ">=13.0.0" }, { name = "semver", specifier = ">=2.7.9" }, + { name = "toml", specifier = ">=0.10.2" }, { name = "urllib3", specifier = ">=2.5" }, ] provides-extras = ["aws", "all"] @@ -2039,6 +2041,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomli" version = "2.4.1"