From da913d234b2a43219127942252036c507622d8d9 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Sun, 9 Aug 2026 02:29:09 +0100 Subject: [PATCH] feat(ENG-13683): add Maven credential helper via shell plugin Maven has no credential-helper protocol, so there is nothing to install a launcher for. This adds a shell plugin instead: an `mvn` shim that wraps every invocation in `cloudsmith exec`, which provisions credentials for that single run and cleans them up afterwards. `cloudsmith credential-helper install maven --org --repo ` writes the shim into the Cloudsmith shims directory and records the binding (org, repo, resolved download/upload hosts, registry id) in `package-managers.ini`. `credential-helper shell-init` emits the bash/zsh/fish snippet that puts the shims directory first on PATH. A wrapped run resolves dependencies through an ephemeral, mode-0600 `settings.xml` injected via `mvn -s` and deleted when the run ends, so no token is ever written to durable configuration. Maven matches a `` to a repository by id alone, with no host check, so the injected download credential is bound to a random id minted per invocation; the stable id named in `distributionManagement` is supplied only when the invocation is actually a deploy. Publishing stays opt-in: `install` prints the snippet to paste into `pom.xml`. A user-supplied `-s/--settings` runs Maven unwrapped, with a warning, rather than silently shadowing their file. Custom domains are discovered as for the Docker helper and ranked the way the server ranks overlapping domains, so the binding records the host Cloudsmith would itself serve. A repository-scoped domain identifies the repository on its own, so its URLs carry neither org nor repo segment; because it serves only the repository it was discovered for, `cloudsmith exec --repo ` resets it to the default host for that invocation. Alongside it: - `cloudsmith exec -- ` is callable directly, for CI that would rather not touch PATH. The package manager is detected from the command name; `--org`/`--repo` flags override the stored binding, while the same values from the environment or config.ini apply only when nothing is stored, since OIDC users keep CLOUDSMITH_ORG exported permanently. - `default_domains` gains host resolution by backend kind and by domain type, honouring a trusted `[domains]` override, plus `DomainScope` for what a discovered domain is bound to; `common` gains the URL path-segment rule that follows from it. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 + cloudsmith_cli/cli/commands/__init__.py | 1 + .../commands/credential_helper/__init__.py | 28 +- .../cli/commands/credential_helper/manage.py | 64 +- .../cli/commands/credential_helper/shell.py | 36 + cloudsmith_cli/cli/commands/exec_.py | 59 ++ .../test_credential_helper_maven_installer.py | 707 ++++++++++++++++++ .../test_credential_helper_maven_plugin.py | 618 +++++++++++++++ .../test_credential_helper_maven_runner.py | 660 ++++++++++++++++ .../tests/commands/test_default_domains.py | 74 ++ cloudsmith_cli/cli/utils.py | 12 + cloudsmith_cli/credential_helpers/common.py | 54 +- .../credential_helpers/custom_domains.py | 11 +- .../credential_helpers/default_domains.py | 74 ++ .../credential_helpers/docker/installer.py | 29 +- .../credential_helpers/launchers.py | 24 +- .../credential_helpers/maven/__init__.py | 2 + .../credential_helpers/maven/installer.py | 395 ++++++++++ .../shellplugin/__init__.py | 2 + .../credential_helpers/shellplugin/config.py | 170 +++++ .../credential_helpers/shellplugin/maven.py | 227 ++++++ .../credential_helpers/shellplugin/plugin.py | 80 ++ .../credential_helpers/shellplugin/runner.py | 237 ++++++ .../shellplugin/shellinit.py | 49 ++ .../credential_helpers/shellplugin/shims.py | 44 ++ .../templates/maven_deploy_server.xml.tmpl | 5 + .../maven_distribution_management.xml.tmpl | 10 + .../templates/maven_settings.xml.tmpl | 30 + 28 files changed, 3668 insertions(+), 37 deletions(-) create mode 100644 cloudsmith_cli/cli/commands/credential_helper/shell.py create mode 100644 cloudsmith_cli/cli/commands/exec_.py create mode 100644 cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_installer.py create mode 100644 cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_plugin.py create mode 100644 cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_runner.py create mode 100644 cloudsmith_cli/credential_helpers/maven/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/maven/installer.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/__init__.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/config.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/maven.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/plugin.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/runner.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/shellinit.py create mode 100644 cloudsmith_cli/credential_helpers/shellplugin/shims.py create mode 100644 cloudsmith_cli/templates/maven_deploy_server.xml.tmpl create mode 100644 cloudsmith_cli/templates/maven_distribution_management.xml.tmpl create mode 100644 cloudsmith_cli/templates/maven_settings.xml.tmpl diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b938a3c..7819bc86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith domains list` lists the hosts Cloudsmith can authenticate as a versioned JSON document: `{"version": 1, "domains": [{"host": ..., "format": ..., "type": ..., "domain_type": ..., "org": ..., "repository": ..., "primary": ..., "created_at": ...}]}`. The built-in list can be replaced by a `[domains]` section in a trusted `config.ini` — each entry maps a hostname to the format it serves, or to `download`/`upload` — for dedicated deployments. An organisation's own custom domains are listed ahead of the built-in hosts, and a custom domain that is disabled or not yet validated is left out entirely, since it serves nothing. `--format` and `--repo` narrow the list to the hosts usable for a package format or repository, most-preferred first, and `--domain-type` to those with one purpose: `download`, `upload`, `api` or `native_api`. - The Cloudsmith organisation is now named by `--org`, with `--organization` and `--oidc-org` accepted as aliases for the same option, and `org`, `organization` or `oidc_org` accepted in `config.ini`. `--oidc-org` named the setting after the first feature that wanted it; it is read by custom-domain discovery as well as OIDC token exchange, so it is now named after what it is. The `CLOUDSMITH_ORG` environment variable is unchanged, and `credential-helper install` no longer has a separate `--org` of its own. +- `cloudsmith credential-helper install maven --org --repo ` sets up transparent Maven authentication. Maven has no credential-helper protocol, so the CLI installs a shell plugin instead: an `mvn` shim (activated by putting the shims directory first on `PATH` via `cloudsmith credential-helper shell-init`) that wraps every invocation in `cloudsmith exec`. Wrapped runs resolve dependencies from the bound Cloudsmith repository through an ephemeral, mode-0600 `settings.xml` that is injected via `mvn -s` and deleted when the run ends, so no token is ever written to durable configuration. Note that wrapped runs do not consult `~/.m2/settings.xml`; passing your own `-s/--settings` runs Maven unwrapped, with a warning. Publishing via `mvn deploy` is opt-in: `install` prints the `distributionManagement` snippet to add to `pom.xml`. Custom download/upload domains are discovered automatically from the organisation, as for the Docker helper, and a domain bound to the repository being installed is preferred over an organisation-wide one, while a domain bound to a *different* repository is never used. Which kind each host is — organisation-wide or bound to this repository — is recorded in `package-managers.ini` alongside it, so wrapped `mvn` runs need no further lookup. A repository-scoped custom domain identifies the repository on its own, so its URLs carry neither an organisation nor a repository path segment (`https://dl-prod.acme.com/basic/maven/` rather than `.../basic///maven/`). Because such a host serves only the repository it was discovered for, `cloudsmith exec --repo ` resets it back to the default host for that one invocation rather than reusing it for an unrelated repository. Maven matches a ``'s credentials to a repository by id alone, with no host check, so a wrapped run binds the injected download credential to an unguessable id minted for the binding at install time rather than a fixed, guessable one — a `pom.xml` that declares a repository under a guessed server id can no longer obtain the token on an ordinary build. That id is stable across runs, because Maven's local repository records which repository each cached artifact came from and a new id every run would re-download everything and break offline builds. The stable server id you put in `distributionManagement` is supplied only when the invocation is actually a deploy (`deploy`, `deploy:*`, `release:perform`, or a goal given by its fully-qualified plugin coordinates like `org.apache.maven.plugins:maven-deploy-plugin:3.1.1:deploy-file`), since that is the only case that needs it — the rest of the release lifecycle, `release:prepare` included, publishes nothing and is not given it. +- `cloudsmith exec -- ` runs a package-manager command with Cloudsmith credentials provisioned for that single run and cleaned up afterwards — the same machinery the `mvn` shim uses, callable directly (for example in CI, without touching `PATH`). The package manager is detected from the command name; `--org`/`--repo` flags override the stored binding for one invocation, while `CLOUDSMITH_ORG`/`CLOUDSMITH_REPO` from the environment apply only when no binding is stored. Running a command whose helper is not configured is an error that names the `install` command to run, and help/version invocations pass through without credentials. +- `cloudsmith credential-helper shell-init` prints the shell initialisation (bash, zsh and fish) that puts the Cloudsmith shims directory first on `PATH`, for `eval "$(cloudsmith credential-helper shell-init)"` in a shell rc file. ## [1.21.0] - 2026-08-03 diff --git a/cloudsmith_cli/cli/commands/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index 0ad6fc2b..63162c10 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -11,6 +11,7 @@ domains, download, entitlements, + exec_, help_, list_, login, diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index 91d12bb9..67ab995b 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -12,6 +12,7 @@ from .docker import docker as docker_cmd from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd +from .shell import shell_init @click.group() @@ -19,21 +20,38 @@ def credential_helper(): """ Credential helpers for package managers. - These commands provide credentials for package managers like Docker. - Use ``install`` to set up the on-PATH launcher and configure the package - manager automatically, or run the runtime command directly for debugging. + Use ``install`` to set up a helper and configure the package manager + automatically. Docker uses a native credential-helper launcher; Maven has + no such protocol, so it uses an ``mvn`` shim plus ``cloudsmith exec`` — + activate the shims directory with ``credential-helper shell-init``. + + ``generic`` emits a machine-readable credential for tools that shell out to + the CLI instead of importing it; ``cloudsmith domains list`` names the hosts + it can be used against. Examples: - # Install Docker credential helper + + \b + # Install the Docker credential helper $ cloudsmith credential-helper install docker - # Test Docker credential helper directly + \b + # Install the Maven helper for one repository + $ cloudsmith credential-helper install maven --org my-org --repo my-repo + + \b + # Test the Docker credential helper directly $ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker + + \b + # Emit a credential as JSON + $ cloudsmith credential-helper generic """ credential_helper.add_command(docker_cmd, name="docker") credential_helper.add_command(generic_cmd, name="generic") +credential_helper.add_command(shell_init, name="shell-init") credential_helper.add_command(install_cmd, name="install") credential_helper.add_command(uninstall_cmd, name="uninstall") credential_helper.add_command(list_cmd, name="list") diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 35cdc825..d2905cb0 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -13,6 +13,8 @@ import click from ....credential_helpers.docker.installer import DockerInstaller +from ....credential_helpers.maven.installer import MavenInstaller +from ....credential_helpers.shellplugin.config import DEFAULT_REGISTRY_ID from ... import utils from ...decorators import ( common_api_auth_options, @@ -27,6 +29,7 @@ _INSTALLERS: dict[str, type] = { "docker": DockerInstaller, + "maven": MavenInstaller, } @@ -73,7 +76,8 @@ def _get_installer(name: str): "--domain", "domains", multiple=True, - help="Additional registry hostname to configure (repeatable).", + help="Registry hostname to configure (repeatable). Docker adds each host; " + "maven routes each to its download or upload endpoint.", ) @click.option( "--dry-run", @@ -85,7 +89,7 @@ def _get_installer(name: str): "--no-discover", is_flag=True, default=False, - help="Disable automatic discovery of custom Docker domains.", + help="Disable automatic discovery of custom domains.", ) @click.option( "--refresh", @@ -93,6 +97,19 @@ def _get_installer(name: str): default=False, help="Bypass the custom-domain cache and fetch fresh data from the API.", ) +@click.option( + "--repo", + default=None, + envvar="CLOUDSMITH_REPO", + help="Cloudsmith repository slug.", +) +@click.option( + "--registry-id", + default=DEFAULT_REGISTRY_ID, + show_default=True, + help="Id the credentials are registered under in your project config " + "(e.g. the Maven settings.xml/pom.xml id).", +) @common_cli_config_options @common_cli_output_options @common_api_auth_options @@ -107,10 +124,14 @@ def install_cmd( dry_run: bool, no_discover: bool, refresh: bool, + repo: str | None, + registry_id: str, ) -> None: """Install a credential helper launcher and configure the package manager. - HELPER is the name of the credential helper to install (e.g. ``docker``). + HELPER is the name of the credential helper to install (``docker`` or + ``maven``). The maven helper binds one repository, so it requires + ``--org`` and ``--repo``. Examples: @@ -118,6 +139,10 @@ def install_cmd( # Install Docker credential helper $ cloudsmith credential-helper install docker + \b + # Install the Maven helper for one repository + $ cloudsmith credential-helper install maven --org my-org --repo my-repo + \b # Install with a custom domain $ cloudsmith credential-helper install docker --domain my.registry.example.com @@ -131,9 +156,32 @@ def install_cmd( $ cloudsmith credential-helper install docker --no-discover """ installer = _get_installer(helper) + + # opts.org is already normalised on its way through the options object. + repo = utils.normalise_slug(repo) + + per_repo = getattr(installer, "requires_repo", False) + if per_repo and not (opts.org and repo): + click.echo( + f"Error: helper {helper!r} requires --org and --repo.", + err=True, + ) + sys.exit(1) + + # Shell plugins (per-repo) keep their shims in a fixed dir; --bin-dir only + # applies to launcher-based helpers like Docker. + if per_repo: + if bin_dir: + click.echo( + f"Warning: --bin-dir is ignored for {helper!r}; its shim lives " + "in a fixed shims directory.", + err=True, + ) + extra: dict = {"repo": repo, "registry_id": registry_id} + else: + extra = {"bin_dir": bin_dir} try: actions = installer.install( - bin_dir=bin_dir, domains=domains, dry_run=dry_run, discover=not no_discover, @@ -141,8 +189,11 @@ def install_cmd( org=opts.org, credential=opts.credential, api_host=opts.api_host, + **extra, ) - except OSError as exc: + except (OSError, ValueError) as exc: + # ValueError: a trusted [domains] table that declares no host for this + # helper's format, which has no default to fall back on. raise click.ClickException( f"Failed to install {helper!r} credential helper: {exc}" ) @@ -202,8 +253,9 @@ def uninstall_cmd(ctx, opts, helper: str, bin_dir: str | None, dry_run: bool) -> $ cloudsmith credential-helper uninstall docker --dry-run """ installer = _get_installer(helper) + extra = {} if getattr(installer, "requires_repo", False) else {"bin_dir": bin_dir} try: - actions = installer.uninstall(bin_dir=bin_dir, dry_run=dry_run) + actions = installer.uninstall(dry_run=dry_run, **extra) except OSError as exc: raise click.ClickException( f"Failed to uninstall {helper!r} credential helper: {exc}" diff --git a/cloudsmith_cli/cli/commands/credential_helper/shell.py b/cloudsmith_cli/cli/commands/credential_helper/shell.py new file mode 100644 index 00000000..4e9fdacf --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/shell.py @@ -0,0 +1,36 @@ +# Copyright 2026 Cloudsmith Ltd +"""``cloudsmith credential-helper shell-init`` — print shell init for shims. + +Add ``eval "$(cloudsmith credential-helper shell-init)"`` to your shell rc file +to put the Cloudsmith shims directory ahead of the real package-manager +binaries on ``$PATH``. +""" + +import click + +from ....credential_helpers.shellplugin.shellinit import detect_shell, generate_init + + +@click.command(name="shell-init") +@click.option( + "--shell", + "shell_name", + type=click.Choice(["bash", "zsh", "fish"]), + default=None, + help="Target shell. Auto-detected from $SHELL when omitted.", +) +def shell_init(shell_name): + """Print shell init that puts the Cloudsmith shims dir first on PATH. + + Examples: + + \b + # bash / zsh + $ eval "$(cloudsmith credential-helper shell-init)" + + \b + # fish + $ cloudsmith credential-helper shell-init --shell fish | source + """ + shell = shell_name or detect_shell() + click.echo(generate_init(shell), nl=False) diff --git a/cloudsmith_cli/cli/commands/exec_.py b/cloudsmith_cli/cli/commands/exec_.py new file mode 100644 index 00000000..a09cdb3b --- /dev/null +++ b/cloudsmith_cli/cli/commands/exec_.py @@ -0,0 +1,59 @@ +# Copyright 2026 Cloudsmith Ltd +"""CLI/Commands - Run a command with Cloudsmith credentials provisioned.""" + +import sys + +import click +from click.core import ParameterSource + +from ...credential_helpers.shellplugin import runner +from .. import utils +from ..decorators import common_api_auth_options, resolve_credentials +from .main import main + + +@main.command(name="exec", context_settings={"ignore_unknown_options": True}) +@click.option( + "--repo", + default=None, + envvar="CLOUDSMITH_REPO", + help="Cloudsmith repository slug. As a flag this overrides the stored " + "binding; from the environment it applies only when nothing is stored.", +) +@click.argument("command", nargs=-1, type=click.UNPROCESSED, required=True) +@common_api_auth_options +@resolve_credentials +@click.pass_context +def exec_(ctx, opts, repo, command): + """Run a package-manager command authenticated against Cloudsmith. + + Wraps the command so it resolves dependencies from (and publishes to) your + Cloudsmith repository, with credentials injected for that single run and + cleaned up afterwards. Maven runs use an ephemeral ``settings.xml``; your + ``~/.m2/settings.xml`` is not consulted. The package manager is detected + automatically from the command, so just put it after ``--``: + + \b + $ cloudsmith exec -- mvn clean deploy + + ``--org`` overrides the stored binding when passed as a flag; taken from + the environment or ``config.ini`` it applies only when nothing is stored, + the same rule ``--repo`` follows. + """ + + def flag_value(name, value): + source = ctx.get_parameter_source(name) + return value if source == ParameterSource.COMMANDLINE else None + + # opts.org is already normalised on its way through the options object. + repo = utils.normalise_slug(repo) + + exit_code = runner.run( + list(command), + credential=opts.credential, + owner=flag_value("org", opts.org), + repo=flag_value("repo", repo), + default_owner=opts.org, + default_repo=repo, + ) + sys.exit(exit_code) diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_installer.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_installer.py new file mode 100644 index 00000000..6f6700d4 --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_installer.py @@ -0,0 +1,707 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for the Maven installer and the credential-helper CLI wiring.""" + +from __future__ import annotations + +import sys + +import click.testing +import pytest + +from ....cli import config as cli_config +from ....cli.commands.credential_helper.manage import install_cmd, list_cmd +from ....cli.commands.credential_helper.shell import shell_init +from ....cli.commands.exec_ import exec_ +from ....core.api.exceptions import ApiException +from ....core.credentials.models import CredentialResult +from ....credential_helpers import launchers +from ....credential_helpers.backends import BackendKind +from ....credential_helpers.custom_domains import CustomDomain +from ....credential_helpers.default_domains import DomainType +from ....credential_helpers.maven import installer as installer_module +from ....credential_helpers.maven.installer import MavenInstaller +from ....credential_helpers.shellplugin import config, runner + + +@pytest.fixture() +def _home(tmp_path, monkeypatch): + """Point the CLI config dir at a tmp dir so package-managers.ini/shims land under it.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.shellplugin.config.get_default_config_path", + lambda: str(tmp_path), + ) + return tmp_path + + +# --------------------------------------------------------------------------- +# 6. MavenInstaller +# --------------------------------------------------------------------------- + +_INSTALLER_GET_CUSTOM_DOMAINS = ( + "cloudsmith_cli.credential_helpers.maven.installer.get_custom_domains" +) + + +def _domain(host, backend_kind, repo=None): + """A discovered custom domain: no backend kind means the download CDN.""" + return CustomDomain( + host=host, + backend_kind=backend_kind, + enabled=True, + validated=True, + org="acme", + repository=repo, + domain_type=( + DomainType.DOWNLOAD if backend_kind is None else DomainType.NATIVE_API + ), + ) + + +def _fake_discovery(monkeypatch, *, cdn_host=None, upload_host=None, raises=False): + """Mock get_custom_domains: CDN domains carry backend_kind None; Maven == MAVEN. + + Returns a dict that captures the kwargs of the last lookup call. + """ + domains = [] + if cdn_host: + domains.append(_domain(cdn_host, None)) + if upload_host: + domains.append(_domain(upload_host, int(BackendKind.MAVEN))) + + captured = {} + + def _fake(org, **kwargs): + captured.update(kwargs) + if raises: + # What a strict lookup raises: the API layer wraps every failure + # mode, transport errors included, into ApiException. + raise ApiException(status=503, detail="network down") + return domains + + monkeypatch.setattr(_INSTALLER_GET_CUSTOM_DOMAINS, _fake) + return captured + + +def test_maven_install_prefers_a_repository_scoped_domain(_home, monkeypatch): + """A domain bound to the bound repo is more specific than an org-wide one.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + monkeypatch.setattr( + _INSTALLER_GET_CUSTOM_DOMAINS, + lambda org, **_kwargs: [ + _domain("dl.acme.com", None), + _domain("dl-prod.acme.com", None, repo="prod"), + ], + ) + + MavenInstaller().install( + org="acme", + repo="prod", + credential=CredentialResult(api_key="k", source_name="t"), + discover=True, + ) + + entry = config.get_plugin("maven") + assert entry.cdn_host == "dl-prod.acme.com" + assert entry.cdn_scope == "repository" + + +def test_maven_install_ignores_a_domain_scoped_to_another_repo(_home, monkeypatch): + """A domain bound to a different repository cannot serve this binding.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + monkeypatch.setattr( + _INSTALLER_GET_CUSTOM_DOMAINS, + lambda org, **_kwargs: [_domain("dl-dev.acme.com", None, repo="dev")], + ) + + MavenInstaller().install( + org="acme", + repo="prod", + credential=CredentialResult(api_key="k", source_name="t"), + discover=True, + ) + + entry = config.get_plugin("maven") + assert entry.cdn_host == "dl.cloudsmith.io" + assert entry.cdn_scope == "organization" + + +def test_maven_install_domain_selection_is_order_independent(_home, monkeypatch): + """Selecting among several org-scoped domains must not depend on API order. + + _select_domain returned candidates[0] in raw discovery-API order, so two + installs of the same repo could bind different hosts purely because the + server happened to return the list in a different order. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + credential = CredentialResult(api_key="k", source_name="t") + domains = [_domain("zzz.acme.com", None), _domain("aaa.acme.com", None)] + + monkeypatch.setattr(_INSTALLER_GET_CUSTOM_DOMAINS, lambda org, **_kwargs: domains) + MavenInstaller().install( + org="acme", repo="prod", credential=credential, discover=True + ) + first_pick = config.get_plugin("maven").cdn_host + + monkeypatch.setattr( + _INSTALLER_GET_CUSTOM_DOMAINS, lambda org, **_kwargs: list(reversed(domains)) + ) + MavenInstaller().install( + org="acme", repo="prod", credential=credential, discover=True + ) + second_pick = config.get_plugin("maven").cdn_host + + assert first_pick == second_pick == "aaa.acme.com" + + +def test_maven_install_discovers_both_hosts_and_persists(_home, monkeypatch): + """install writes the shim and persists discovered CDN + upload hosts.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + captured = _fake_discovery( + monkeypatch, cdn_host="dl.acme.com", upload_host="maven.acme.com" + ) + + credential = CredentialResult(api_key="k", source_name="t") + MavenInstaller().install( + org="acme", repo="prod", credential=credential, discover=True + ) + + assert captured["credential"] is credential + + assert (config.shims_dir() / "mvn").exists() + entry = config.get_plugin("maven") + assert entry.owner == "acme" + assert entry.repo == "prod" + assert entry.cdn_host == "dl.acme.com" + assert entry.upload_host == "maven.acme.com" + assert entry.registry_id == "cloudsmith" + + +def test_maven_install_defaults_when_no_discovery(_home, monkeypatch): + """discover=False keeps the *.cloudsmith.io default hosts.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + MavenInstaller().install(org="acme", repo="prod", discover=False) + + entry = config.get_plugin("maven") + assert entry.cdn_host == "dl.cloudsmith.io" + assert entry.upload_host == "maven.cloudsmith.io" + + +def test_maven_install_domain_override(_home, monkeypatch): + """--domain overrides the discovered/default download CDN host.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + MavenInstaller().install( + org="acme", repo="prod", domains=("my.cdn.example.com",), discover=False + ) + assert config.get_plugin("maven").cdn_host == "my.cdn.example.com" + + +def test_maven_install_domain_override_routes_a_maven_domain_to_upload( + _home, monkeypatch +): + """Maven speaks to two endpoints, so --domain is routed by backend kind.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + _fake_discovery(monkeypatch, upload_host="maven.acme.example.com") + + MavenInstaller().install( + org="acme", + repo="prod", + credential=CredentialResult(api_key="k", source_name="t"), + domains=("maven.acme.example.com",), + discover=True, + ) + + entry = config.get_plugin("maven") + assert entry.upload_host == "maven.acme.example.com" + assert entry.cdn_host == "dl.cloudsmith.io" + + +def test_maven_install_reports_a_superseded_domain_override(_home, monkeypatch): + """Only one host per role is usable, so a dropped value must be visible.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + actions = MavenInstaller().install( + org="acme", + repo="prod", + domains=("first.cdn.example.com", "second.cdn.example.com"), + discover=False, + ) + + assert config.get_plugin("maven").cdn_host == "second.cdn.example.com" + assert any( + a.startswith("WARNING") and "first.cdn.example.com" in a for a in actions + ) + + +def test_maven_install_defaults_honour_a_domains_config_override( + _home, monkeypatch, tmp_path +): + """A deployment's [domains] table must reach the persisted binding. + + Defaults frozen from the built-in table at import time would pin an + on-premise install to *.cloudsmith.io. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + (tmp_path / "config.ini").write_text( + "[domains]\n" + "cdn.internal.example.com = download\n" + "mvn.internal.example.com = maven\n", + encoding="utf-8", + ) + monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"]) + monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", [str(tmp_path)]) + + MavenInstaller().install(org="acme", repo="prod", discover=False) + + entry = config.get_plugin("maven") + assert entry.cdn_host == "cdn.internal.example.com" + assert entry.upload_host == "mvn.internal.example.com" + + +def test_maven_install_custom_registry_id(_home, monkeypatch): + """--registry-id is persisted for use in settings.xml + pom snippet.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + MavenInstaller().install( + org="acme", repo="prod", discover=False, registry_id="my-cs" + ) + assert config.get_plugin("maven").registry_id == "my-cs" + + +def test_maven_install_prints_distribution_management_snippet(_home, monkeypatch): + """install surfaces a distributionManagement snippet for opt-in deploy.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + + joined = "\n".join(actions) + assert "distributionManagement" in joined + assert "https://maven.cloudsmith.io/acme/prod/" in joined + assert "cloudsmith" in joined + + +def test_maven_install_snippet_notes_exec_overrides_do_not_affect_deploy( + _home, monkeypatch +): + """The snippet warns that --org/--repo on exec do not retarget mvn deploy. + + `exec --org/--repo` retargets downloads, but `mvn deploy` still publishes + to whatever distributionManagement the pom names - written for the + install-time repository - so the mismatch must be visible where the user + copies the snippet from. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + + joined = "\n".join(actions) + assert "--org" in joined and "--repo" in joined + assert "deploy" in joined + + +def test_maven_install_notes_user_settings_not_consulted(_home, monkeypatch): + """install tells the user wrapped runs do not read ~/.m2/settings.xml. + + Mirrors, proxies and other-repo credentials living there silently vanish + from wrapped runs, so the surprise must be announced at install time. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + + assert any("~/.m2/settings.xml" in action for action in actions) + + +def test_maven_install_snippet_custom_upload_domain_drops_org(_home, monkeypatch): + """With a custom upload domain, the deploy snippet URL is org-scoped.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + _fake_discovery(monkeypatch, upload_host="maven.acme.example.com") + + actions = MavenInstaller().install( + org="acme", + repo="prod", + credential=CredentialResult(api_key="k", source_name="t"), + discover=True, + ) + joined = "\n".join(actions) + assert "https://maven.acme.example.com/prod/" in joined + assert "maven.acme.example.com/acme/prod/" not in joined + + +def test_maven_install_dry_run_writes_nothing(_home, monkeypatch): + """dry_run: no shim, no package-managers.ini entry, returns 'would' actions.""" + actions = MavenInstaller().install( + org="acme", repo="prod", discover=False, dry_run=True + ) + assert not (config.shims_dir() / "mvn").exists() + assert config.get_plugin("maven") is None + assert any("would" in a.lower() for a in actions) + + +def test_maven_install_path_warning(_home, monkeypatch): + """A WARNING is returned when the shims dir is not on PATH.""" + monkeypatch.setenv("PATH", "/usr/bin:/usr/local/bin") + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + warnings = [a for a in actions if a.startswith("WARNING")] + assert warnings + assert any("PATH" in a for a in warnings) + + +def test_maven_install_warns_when_cloudsmith_command_is_unresolvable( + _home, monkeypatch +): + """The shim execs `cloudsmith exec -- mvn`; an unresolvable cloudsmith breaks it. + + An inactive venv makes `cloudsmith`, and therefore every wrapped `mvn`, + fail machine-wide - not just this shim - so it needs its own warning + distinct from the shims-dir-not-on-PATH one. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + monkeypatch.setattr(installer_module.shutil, "which", lambda _name: None) + monkeypatch.delattr(sys, "frozen", raising=False) + + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + + assert any( + a.startswith("WARNING") and "cloudsmith" in a and "PATH" in a for a in actions + ) + + +def test_maven_install_no_cloudsmith_warning_when_resolvable(_home, monkeypatch): + """No warning when `cloudsmith` is actually on PATH.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + monkeypatch.setattr( + installer_module.shutil, "which", lambda _name: "/usr/bin/cloudsmith" + ) + monkeypatch.delattr(sys, "frozen", raising=False) + + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + + assert not any(a.startswith("WARNING") and "`cloudsmith`" in a for a in actions) + + +def test_maven_install_no_cloudsmith_warning_when_frozen(_home, monkeypatch): + """A frozen build points the shim at sys.executable, so PATH is irrelevant.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + monkeypatch.setattr(installer_module.shutil, "which", lambda _name: None) + monkeypatch.setattr(sys, "frozen", True, raising=False) + + actions = MavenInstaller().install(org="acme", repo="prod", discover=False) + + assert not any(a.startswith("WARNING") and "`cloudsmith`" in a for a in actions) + + +def test_maven_install_discovery_failure_is_graceful(_home, monkeypatch): + """Discovery errors degrade to a WARNING; defaults still install.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + _fake_discovery(monkeypatch, raises=True) + + actions = MavenInstaller().install( + org="acme", + repo="prod", + credential=CredentialResult(api_key="k", source_name="t"), + discover=True, + ) + assert (config.shims_dir() / "mvn").exists() + assert config.get_plugin("maven").cdn_host == "dl.cloudsmith.io" + assert any(a.startswith("WARNING") for a in actions) + + +def test_maven_uninstall_removes_shim_and_entry(_home, monkeypatch): + """uninstall removes the shim and drops the package-managers.ini entry.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + installer = MavenInstaller() + installer.install(org="acme", repo="prod", discover=False) + assert (config.shims_dir() / "mvn").exists() + + installer.uninstall() + assert not (config.shims_dir() / "mvn").exists() + assert config.get_plugin("maven") is None + + +def test_maven_status_launcher_is_str_or_none(_home, monkeypatch): + """status()['launcher'] is None before install and a str afterwards.""" + monkeypatch.setenv("PATH", str(config.shims_dir())) + installer = MavenInstaller() + + assert installer.status()["launcher"] is None + + installer.install(org="acme", repo="prod", discover=False) + launcher = installer.status()["launcher"] + assert isinstance(launcher, str) + assert launcher.endswith("mvn") + + +def test_maven_status_and_uninstall_find_the_windows_shim(_home, monkeypatch): + """The shim is `mvn.cmd` on Windows; probing bare `mvn` would miss it.""" + monkeypatch.setattr(launchers, "_is_windows", lambda: True) + monkeypatch.setenv("PATH", str(config.shims_dir())) + installer = MavenInstaller() + installer.install(org="acme", repo="prod", discover=False) + + shim = config.shims_dir() / "mvn.cmd" + assert shim.exists() + assert installer.status()["launcher"] == str(shim) + assert any("would remove shim" in a for a in installer.uninstall(dry_run=True)) + + +# --------------------------------------------------------------------------- +# 7. CLI wiring — exec + shell-init + manage +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def cli_runner(monkeypatch): + """A CliRunner whose invocation starts from a clean Options object. + + Options is a process-wide thread-local, so an organisation resolved by one + invocation would otherwise satisfy the next one's --org requirement. + """ + monkeypatch.delattr(cli_config.OPTIONS, "value", raising=False) + return click.testing.CliRunner() + + +def test_exec_cmd_passes_command_through_and_propagates_exit_code( + cli_runner, monkeypatch +): + """`cloudsmith exec -- mvn install` calls runner.run([...]) and returns its code.""" + captured = {} + + def _fake_run(command, **kwargs): + captured["command"] = command + return 7 + + monkeypatch.setattr(runner, "run", _fake_run) + + result = cli_runner.invoke(exec_, ["--", "mvn", "install", "-DskipTests"]) + assert result.exit_code == 7 + assert captured["command"] == ["mvn", "install", "-DskipTests"] + + +def test_exec_cmd_env_org_repo_are_defaults_not_overrides(cli_runner, monkeypatch): + """CLOUDSMITH_ORG/CLOUDSMITH_REPO fill in when nothing is stored, but must + not override a stored binding (OIDC users keep CLOUDSMITH_ORG exported).""" + captured = {} + + def _fake_run(command, **kwargs): + captured.update(kwargs) + return 0 + + monkeypatch.setattr(runner, "run", _fake_run) + monkeypatch.setenv("CLOUDSMITH_ORG", "env-org") + monkeypatch.setenv("CLOUDSMITH_REPO", "env-repo") + + result = cli_runner.invoke(exec_, ["-k", "fake-key", "--", "mvn", "install"]) + + assert result.exit_code == 0 + assert captured["owner"] is None + assert captured["repo"] is None + assert captured["default_owner"] == "env-org" + assert captured["default_repo"] == "env-repo" + + +def test_exec_cmd_flag_org_repo_are_overrides(cli_runner, monkeypatch): + """--org/--repo passed on the command line override the stored binding.""" + captured = {} + + def _fake_run(command, **kwargs): + captured.update(kwargs) + return 0 + + monkeypatch.setattr(runner, "run", _fake_run) + monkeypatch.setenv("CLOUDSMITH_ORG", "env-org") + + result = cli_runner.invoke( + exec_, + ["-k", "fake-key", "--org", "flag-org", "--repo", "flag-repo", "--", "mvn"], + ) + + assert result.exit_code == 0 + assert captured["owner"] == "flag-org" + assert captured["repo"] == "flag-repo" + + +def test_shell_init_cmd_explicit_shell(cli_runner, _home): + """`shell-init --shell bash` prints the PATH prepend for the shims dir.""" + result = cli_runner.invoke(shell_init, ["--shell", "bash"]) + assert result.exit_code == 0 + assert f'export PATH="{config.shims_dir()}:$PATH"' in result.output + + +def test_shell_init_cmd_detects_fish(cli_runner, monkeypatch): + """`shell-init` with $SHELL=fish emits fish syntax.""" + monkeypatch.setenv("SHELL", "/usr/bin/fish") + result = cli_runner.invoke(shell_init, []) + assert result.exit_code == 0 + assert "fish_add_path" in result.output + + +def test_manage_list_includes_maven(cli_runner): + """`credential-helper list` shows the maven helper.""" + result = cli_runner.invoke(list_cmd, []) + assert result.exit_code == 0, result.output + assert "maven" in result.output + + +def test_manage_install_maven_dry_run(cli_runner, _home): + """`install maven --org --repo --no-discover --dry-run` exits 0 with a plan.""" + result = cli_runner.invoke( + install_cmd, + ["maven", "--org", "acme", "--repo", "prod", "--no-discover", "--dry-run"], + ) + assert result.exit_code == 0, result.output + assert "would" in result.output.lower() or "dry run" in result.output.lower() + + +def test_manage_install_maven_ignores_bin_dir(cli_runner, _home, tmp_path): + """--bin-dir does not apply to shell plugins: the shim lands in the shims dir.""" + other = tmp_path / "other-bin" + result = cli_runner.invoke( + install_cmd, + [ + "maven", + "--org", + "acme", + "--repo", + "prod", + "--no-discover", + "--bin-dir", + str(other), + ], + ) + assert result.exit_code == 0, result.output + assert (config.shims_dir() / "mvn").exists() + assert not (other / "mvn").exists() + assert "--bin-dir is ignored" in result.stderr + + +def test_manage_install_maven_requires_repo(cli_runner, _home, monkeypatch): + """Installing maven without --repo fails clearly.""" + monkeypatch.delenv("CLOUDSMITH_REPO", raising=False) + result = cli_runner.invoke(install_cmd, ["maven", "--org", "acme", "--no-discover"]) + assert result.exit_code != 0 + + +def test_manage_install_maven_rejects_a_padded_env_org(cli_runner, _home, monkeypatch): + """click passes an env var through verbatim, so it is normalised here. + + A padded CLOUDSMITH_ORG would otherwise be written into the binding as a + slug and produce malformed URLs for every wrapped run. + """ + monkeypatch.setenv("CLOUDSMITH_ORG", " ") + result = cli_runner.invoke( + install_cmd, ["maven", "--repo", "prod", "--no-discover"] + ) + + assert result.exit_code != 0 + assert config.get_plugin("maven") is None + + +def test_manage_install_maven_requires_org(cli_runner, _home, monkeypatch): + """Installing maven without --org fails clearly (no malformed empty-org URLs).""" + monkeypatch.delenv("CLOUDSMITH_ORG", raising=False) + result = cli_runner.invoke( + install_cmd, ["maven", "--repo", "prod", "--no-discover"] + ) + assert result.exit_code != 0 + + +def test_maven_install_never_binds_an_upload_domain_as_the_cdn(_home, monkeypatch): + """The two format-less endpoints must be told apart by domain type. + + The download CDN and the generic upload endpoint both carry + `backend_kind is None`, so matching on backend kind alone let an upload + domain win the CDN slot and 404 every artifact. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + upload = CustomDomain( + host="upload.acme.com", + backend_kind=None, + enabled=True, + validated=True, + org="acme", + domain_type=DomainType.UPLOAD, + created_at="2024-01-01T00:00:00Z", + ) + download = CustomDomain( + host="dl.acme.com", + backend_kind=None, + enabled=True, + validated=True, + org="acme", + domain_type=DomainType.DOWNLOAD, + created_at="2025-01-01T00:00:00Z", + ) + monkeypatch.setattr( + _INSTALLER_GET_CUSTOM_DOMAINS, lambda org, **_kwargs: [upload, download] + ) + + MavenInstaller().install( + org="acme", + repo="prod", + credential=CredentialResult(api_key="k", source_name="t"), + discover=True, + ) + + assert config.get_plugin("maven").cdn_host == "dl.acme.com" + + +def test_maven_install_refuses_a_domain_bound_to_another_repo(_home, monkeypatch): + """--domain must apply the same repository check automatic selection does. + + Adopting it recorded cdn_scope=repository, so URLs dropped both path + segments and every build silently resolved from the other repository. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + monkeypatch.setattr( + _INSTALLER_GET_CUSTOM_DOMAINS, + lambda org, **_kwargs: [_domain("dl-staging.acme.com", None, repo="staging")], + ) + + actions = MavenInstaller().install( + org="acme", + repo="prod", + credential=CredentialResult(api_key="k", source_name="t"), + domains=("dl-staging.acme.com",), + discover=True, + ) + + entry = config.get_plugin("maven") + assert entry.cdn_host == "dl.cloudsmith.io" + assert entry.cdn_scope == "organization" + assert any(a.startswith("WARNING") and "dl-staging.acme.com" in a for a in actions) + + +def test_maven_install_says_an_undiscovered_domain_is_the_download_host( + _home, monkeypatch +): + """An override discovery never saw carries nothing to route it by. + + Taking it as the download host silently is how a native Maven upload domain + ended up serving dependency resolution, so the assumption is stated. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + + actions = MavenInstaller().install( + org="acme", + repo="prod", + domains=("maven.acme.example.com",), + discover=False, + ) + + assert config.get_plugin("maven").cdn_host == "maven.acme.example.com" + assert any("was not discovered" in a for a in actions) + + +def test_maven_install_persists_the_binding_before_writing_the_shim(_home, monkeypatch): + """A shim without a binding blocks every mvn on the machine. + + The runner refuses to run a command it has no binding for, so a shim + written ahead of a failed set_plugin left Maven unusable rather than + merely uninstalled. + """ + monkeypatch.setenv("PATH", str(config.shims_dir())) + + def _explode(*_args, **_kwargs): + raise OSError("read-only config dir") + + monkeypatch.setattr(installer_module.config, "set_plugin", _explode) + + with pytest.raises(OSError): + MavenInstaller().install(org="acme", repo="prod", discover=False) + + assert not (config.shims_dir() / "mvn").exists() diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_plugin.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_plugin.py new file mode 100644 index 00000000..2829f297 --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_plugin.py @@ -0,0 +1,618 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for the Maven shell plugin: binding config, settings.xml, shims, shell-init.""" + +from __future__ import annotations + +import re +import stat +import sys +from pathlib import Path + +import pytest + +from ....cli import config as cli_config +from ....credential_helpers.common import is_standard_cloudsmith_host, repo_path_segment +from ....credential_helpers.default_domains import DomainScope +from ....credential_helpers.shellplugin import config, maven as maven_module +from ....credential_helpers.shellplugin.maven import ( + MavenPlugin, + build_settings_xml, + download_url, + upload_url, +) +from ....credential_helpers.shellplugin.shellinit import detect_shell, generate_init +from ....credential_helpers.shellplugin.shims import ( + remove_shim, + shim_target_cmd, + write_shim, +) + +# --------------------------------------------------------------------------- +# 1. config — PluginEntry + package-managers.ini +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def _home(tmp_path, monkeypatch): + """Point the CLI config dir at a tmp dir so package-managers.ini/shims land under it.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.shellplugin.config.get_default_config_path", + lambda: str(tmp_path), + ) + return tmp_path + + +def test_config_path_and_shims_dir_in_config_dir(_home): + """config_path() and shims_dir() live in the CLI config directory.""" + assert config.config_path() == _home / "package-managers.ini" + assert config.shims_dir() == _home / "shims" + + +def test_load_plugins_missing_file_returns_empty(_home): + """load_plugins() returns {} when package-managers.ini does not exist.""" + assert config.load_plugins() == {} + + +def test_set_then_get_plugin_roundtrip(_home): + """set_plugin persists every field; get_plugin reads it back.""" + # Every host differs from its default, so a field that is silently dropped + # comes back as the default rather than matching. + entry = config.PluginEntry( + owner="acme", + repo="prod", + cdn_host="dl.acme.example.com", + upload_host="maven.acme.example.com", + registry_id="acme-registry", + ) + config.set_plugin("maven", entry) + + loaded = config.get_plugin("maven") + assert loaded == entry + # Persisted on disk as an INI section in the CLI config dir. + text = config.config_path().read_text(encoding="utf-8") + assert "[package-manager:maven]" in text + assert "owner = acme" in text + assert "cdn_host = dl.acme.example.com" in text + + +def test_get_plugin_absent_returns_none(_home): + """get_plugin returns None for a format with no entry.""" + assert config.get_plugin("maven") is None + + +def test_remove_plugin(_home): + """remove_plugin deletes the entry (True), and is a no-op (False) afterwards.""" + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + assert config.remove_plugin("maven") is True + assert config.get_plugin("maven") is None + assert config.remove_plugin("maven") is False + + +def test_plugin_entry_from_dict_tolerates_missing_optionals(_home): + """PluginEntry.from_dict fills defaults for absent host/registry_id keys.""" + entry = config.PluginEntry.from_dict({"owner": "acme", "repo": "prod"}) + assert entry.owner == "acme" + assert entry.repo == "prod" + assert entry.cdn_host == "dl.cloudsmith.io" + assert entry.upload_host == "maven.cloudsmith.io" + assert entry.registry_id == "cloudsmith" + + +# --------------------------------------------------------------------------- +# 2. maven.build_settings_xml + MavenPlugin +# --------------------------------------------------------------------------- + + +_DOWNLOAD_ID_RE = re.compile( + r"(cloudsmith-[0-9a-f]{16})" +) + + +def _download_id(xml: str) -> str: + """Extract the random per-invocation download id from rendered *xml*.""" + match = _DOWNLOAD_ID_RE.search(xml) + assert match is not None, xml + return match.group(1) + + +def test_build_settings_xml_download_server_and_active_profile(): + """settings.xml carries a random-id download + active . + + The download profile/repository/server ids are entirely our own + construction, so they get a fresh unguessable id per call rather than the + stable, guessable registry id - that is the whole point of the fix. + """ + xml = build_settings_xml( + owner="acme", + repo="prod", + token="k_abc", + cdn_host="dl.cloudsmith.io", + server_id="cloudsmith", + download_id="cloudsmith-abcdef0123456789", + ) + + assert "token" in xml + assert "k_abc" in xml + # Download repository + plugin repository point at the CDN basic endpoint. + assert "https://dl.cloudsmith.io/basic/acme/prod/maven/" in xml + assert "" in xml + assert "" in xml + # The stable, guessable registry id is absent unless a deploy is running. + assert "cloudsmith" not in xml + download_id = _download_id(xml) + assert xml.count(f"{download_id}") == 4 + + +def test_build_settings_xml_custom_cdn_host_is_org_scoped(): + """A custom download domain is org-scoped: the segment is dropped.""" + xml = build_settings_xml( + owner="acme", + repo="prod", + token="k_abc", + cdn_host="dl.acme.example.com", + server_id="my-cs", + download_id="cloudsmith-abcdef0123456789", + ) + # Custom domain → /basic//maven/ (no ); default keeps the org. + assert "https://dl.acme.example.com/basic/prod/maven/" in xml + assert "/basic/acme/prod/maven/" not in xml + # The stable registry id (my-cs) is only added for deploy invocations. + assert "my-cs" not in xml + + +def test_build_settings_xml_include_deploy_server_adds_stable_server(): + """include_deploy_server=True adds a second for the registry id. + + The random download id is unaffected - it stays self-consistent within + the file regardless of whether a deploy server is also present. + """ + xml = build_settings_xml( + owner="acme", + repo="prod", + token="k_abc", + cdn_host="dl.cloudsmith.io", + server_id="cloudsmith", + download_id="cloudsmith-abcdef0123456789", + include_deploy_server=True, + ) + + assert "cloudsmith" in xml + download_id = _download_id(xml) + assert download_id != "cloudsmith" + assert xml.count(f"{download_id}") == 4 + assert xml.count("k_abc") == 2 + + +def test_repo_path_segment_org_scoping(): + """Standard hosts keep /; custom domains drop the org.""" + assert is_standard_cloudsmith_host("dl.cloudsmith.io") is True + assert is_standard_cloudsmith_host("maven.cloudsmith.com") is True + assert is_standard_cloudsmith_host("dl-prod.iduffy.cloudsmith.sh") is False + + assert repo_path_segment("acme", "prod", "dl.cloudsmith.io") == "acme/prod" + assert repo_path_segment("acme", "prod", "dl-prod.iduffy.example.sh") == "prod" + + +def test_maven_download_url_default_keeps_org_custom_drops_it(): + """download_url keeps for dl.cloudsmith.io, drops it for custom domains.""" + assert ( + download_url("acme", "prod", "dl.cloudsmith.io") + == "https://dl.cloudsmith.io/basic/acme/prod/maven/" + ) + assert ( + download_url("acme", "prod", "dl.acme.example.com") + == "https://dl.acme.example.com/basic/prod/maven/" + ) + + +def test_maven_upload_url_default_keeps_org_custom_drops_it(): + """upload_url keeps for maven.cloudsmith.io, drops it for custom domains.""" + assert ( + upload_url("acme", "prod", "maven.cloudsmith.io") + == "https://maven.cloudsmith.io/acme/prod/" + ) + assert ( + upload_url("acme", "prod", "maven.acme.example.com") + == "https://maven.acme.example.com/prod/" + ) + + +def test_download_url_keeps_org_for_a_trusted_domains_service_host( + monkeypatch, tmp_path +): + """An on-prem [domains] service host is org-scoped, like a built-in host. + + A host declared in a trusted [domains] table is the deployment's own + service host - the equivalent of dl.cloudsmith.io - not a genuinely + discovered custom domain, so dropping the org would resolve every wrapped + run against the wrong path. + """ + (tmp_path / "config.ini").write_text( + "[domains]\ndl.acme-onprem.com = download\nmvn.acme-onprem.com = maven\n", + encoding="utf-8", + ) + monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"]) + monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", [str(tmp_path)]) + + assert ( + download_url("my-org", "my-repo", "dl.acme-onprem.com") + == "https://dl.acme-onprem.com/basic/my-org/my-repo/maven/" + ) + # A genuinely discovered custom domain (not in the [domains] table) still + # drops the org. + assert ( + download_url("my-org", "my-repo", "dl.discovered.example.com") + == "https://dl.discovered.example.com/basic/my-repo/maven/" + ) + + +def test_repo_path_segment_repository_scope_drops_org_and_repo(): + """A repository-scoped domain identifies the repo, so nothing remains.""" + assert ( + repo_path_segment("acme", "prod", "dl-prod.acme.com", DomainScope.REPOSITORY) + == "" + ) + # A standard host is never a custom domain, so the scope cannot apply. + assert ( + repo_path_segment("acme", "prod", "dl.cloudsmith.io", DomainScope.REPOSITORY) + == "acme/prod" + ) + + +def test_maven_urls_for_a_repository_scoped_domain(): + """An empty segment must not leave a doubled slash in the URL.""" + assert ( + download_url("acme", "prod", "dl-prod.acme.com", DomainScope.REPOSITORY) + == "https://dl-prod.acme.com/basic/maven/" + ) + assert ( + upload_url("acme", "prod", "mvn-prod.acme.com", DomainScope.REPOSITORY) + == "https://mvn-prod.acme.com/" + ) + + +def test_download_and_upload_url_params_are_annotated(): + """download_url/upload_url are annotated, like the rest of the module. + + The module has `from __future__ import annotations`, so __annotations__ + holds the unevaluated source strings rather than the resolved types. + """ + assert download_url.__annotations__ == { + "owner": "str", + "repo": "str", + "cdn_host": "str", + "scope": "DomainScope", + "return": "str", + } + assert upload_url.__annotations__ == { + "owner": "str", + "repo": "str", + "upload_host": "str", + "scope": "DomainScope", + "return": "str", + } + + +def test_maven_urls_default_to_organisation_scope(): + """Omitting the scope keeps the existing organisation-wide URLs.""" + assert ( + download_url("acme", "prod", "dl.acme.com") + == "https://dl.acme.com/basic/prod/maven/" + ) + assert download_url("acme", "prod", "dl.cloudsmith.io") == ( + "https://dl.cloudsmith.io/basic/acme/prod/maven/" + ) + + +def test_build_settings_xml_escapes_token(): + """A token with XML metacharacters is escaped in the password element.""" + xml = build_settings_xml( + owner="acme", + repo="prod", + token="aa<b&c" in xml + assert "ak_abc" in xml + assert download_url(entry.owner, entry.repo, entry.cdn_host) in xml + assert stat.S_IMODE(settings_path.stat().st_mode) == 0o600 + assert result.temp_dirs == [str(settings_path.parent)] + assert not result.env + + +def test_maven_plugin_provision_non_deploy_omits_stable_server_id(): + """A non-deploy invocation's settings.xml has no stable registry_id server. + + Asserted against a non-default registry_id so the test cannot pass by + coincidentally matching the default "cloudsmith" download-related text. + """ + entry = config.PluginEntry(owner="acme", repo="prod", registry_id="acme-registry") + result = MavenPlugin().provision(entry, token="k_abc", args=["install"]) + + xml = Path(result.prepend_args[1]).read_text(encoding="utf-8") + assert "acme-registry" not in xml + + +def test_maven_plugin_provision_deploy_includes_stable_server_id(): + """A deploy invocation's settings.xml carries the stable registry_id + server alongside the random download id.""" + entry = config.PluginEntry(owner="acme", repo="prod", registry_id="acme-registry") + result = MavenPlugin().provision(entry, token="k_abc", args=["deploy"]) + + xml = Path(result.prepend_args[1]).read_text(encoding="utf-8") + assert "acme-registry" in xml + assert _download_id(xml) is not None + + +@pytest.mark.parametrize( + "args,is_deploy", + [ + (["deploy"], True), + (["deploy:deploy-file"], True), + (["release:perform"], True), + # A goal given by its fully-qualified plugin coordinates is the same + # goal spelled out in full, not a different one. + (["org.apache.maven.plugins:maven-deploy-plugin:3.1.1:deploy"], True), + (["install"], False), + (["compile"], False), + (["test"], False), + (["dependency:resolve"], False), + # Merely containing the word "deploy" must not count - especially + # the property that explicitly disables it. + (["deploy-helper:run"], False), + (["install", "-Dmaven.deploy.skip=true"], False), + ], +) +def test_maven_plugin_provision_deploy_detection(args, is_deploy): + """The stable server appears only for deploy goals, however spelled.""" + entry = config.PluginEntry(owner="acme", repo="prod", registry_id="acme-registry") + result = MavenPlugin().provision(entry, token="k_abc", args=args) + + xml = Path(result.prepend_args[1]).read_text(encoding="utf-8") + assert ("acme-registry" in xml) is is_deploy + + +def test_maven_plugin_provision_download_id_is_stable_for_a_binding(): + """Successive runs of one binding reuse its download id. + + Maven's local repository records which repository id an artifact came from, + so a fresh id per run makes every cached artifact read as coming from an + unknown repository: builds re-download everything and `mvn -o` fails + outright on artifacts already sitting in ~/.m2. + """ + entry = config.PluginEntry(owner="acme", repo="prod") + + first = MavenPlugin().provision(entry, token="k_abc", args=["install"]) + second = MavenPlugin().provision(entry, token="k_abc", args=["install"]) + + first_id = _download_id(Path(first.prepend_args[1]).read_text(encoding="utf-8")) + second_id = _download_id(Path(second.prepend_args[1]).read_text(encoding="utf-8")) + assert first_id == second_id == entry.download_id + + +def test_download_id_is_unguessable_and_per_install(): + """Two installs get different ids, so one is no guide to another's. + + Maven matches a to a repository by id alone with no host check, so + a guessable id would let any checked-out pom.xml claim the token. + """ + first = config.PluginEntry(owner="acme", repo="prod") + second = config.PluginEntry(owner="acme", repo="prod") + + assert first.download_id != second.download_id + assert _DOWNLOAD_ID_RE.search(f"{first.download_id}") + + +def test_maven_plugin_provision_download_id_consistent_across_blocks(): + """The download id matches on the profile, both repository blocks and the + active profile - a mismatch would silently break resolution.""" + entry = config.PluginEntry(owner="acme", repo="prod") + result = MavenPlugin().provision(entry, token="k_abc", args=["install"]) + + xml = Path(result.prepend_args[1]).read_text(encoding="utf-8") + download_id = _download_id(xml) + assert xml.count(f"{download_id}") == 4 + + +def test_maven_plugin_provision_token_once_per_server_escaped(): + """The token appears exactly once per emitted , XML-escaped.""" + entry = config.PluginEntry(owner="acme", repo="prod", registry_id="acme-registry") + result = MavenPlugin().provision(entry, token="aa<b&c") == 2 + assert "aacme-registry" in xml) is is_deploy diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_runner.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_runner.py new file mode 100644 index 00000000..2cb4ddd8 --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_runner.py @@ -0,0 +1,660 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for `cloudsmith exec`: binary resolution, provisioning, and cleanup.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from ....core.credentials.models import CredentialResult +from ....credential_helpers.shellplugin import config, maven, plugin, runner +from ....credential_helpers.shellplugin.runner import ( + _drop_repository_scoped_hosts, + resolve_real_binary, +) + + +@pytest.fixture() +def _home(tmp_path, monkeypatch): + """Point the CLI config dir at a tmp dir so package-managers.ini/shims land under it.""" + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.shellplugin.config.get_default_config_path", + lambda: str(tmp_path), + ) + return tmp_path + + +# --------------------------------------------------------------------------- +# 5. runner — resolve_real_binary + run +# --------------------------------------------------------------------------- + + +def _make_executable(directory: Path, name: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + path = directory / name + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + return path + + +def test_resolve_real_binary_skips_excluded_dir(tmp_path, monkeypatch): + """resolve_real_binary skips the shims dir and finds the real binary.""" + shims = tmp_path / "shims" + real = tmp_path / "real" + _make_executable(shims, "mvn") + real_mvn = _make_executable(real, "mvn") + monkeypatch.setenv("PATH", os.pathsep.join([str(shims), str(real)])) + + resolved = resolve_real_binary("mvn", exclude_dirs=[str(shims)]) + assert resolved == str(real_mvn) + + +def test_resolve_real_binary_none_when_only_excluded(tmp_path, monkeypatch): + """resolve_real_binary returns None when the only match is excluded.""" + shims = tmp_path / "shims" + _make_executable(shims, "mvn") + monkeypatch.setenv("PATH", str(shims)) + + assert resolve_real_binary("mvn", exclude_dirs=[str(shims)]) is None + + +def test_resolve_real_binary_excludes_symlinked_shims_dir(tmp_path, monkeypatch): + """A PATH entry that is a symlink to the shims dir must not resolve the shim. + + Returning the shim here re-enters `cloudsmith exec` forever (fork bomb). + """ + shims = tmp_path / "shims" + real = tmp_path / "real" + _make_executable(shims, "mvn") + real_mvn = _make_executable(real, "mvn") + linked = tmp_path / "linked-shims" + linked.symlink_to(shims, target_is_directory=True) + monkeypatch.setenv("PATH", os.pathsep.join([str(linked), str(real)])) + + assert resolve_real_binary("mvn", exclude_dirs=[str(shims)]) == str(real_mvn) + + +def test_resolve_real_binary_excludes_symlink_to_shim_binary(tmp_path, monkeypatch): + """A symlink pointing at a shim binary from another dir is excluded too.""" + shims = tmp_path / "shims" + shim = _make_executable(shims, "mvn") + aliases = tmp_path / "aliases" + aliases.mkdir() + (aliases / "mvn").symlink_to(shim) + monkeypatch.setenv("PATH", str(aliases)) + + assert resolve_real_binary("mvn", exclude_dirs=[str(shims)]) is None + + +def test_run_skip_auth_passes_through_without_provisioning(_home, monkeypatch): + """`mvn --version` execs the real binary directly, no settings.xml.""" + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["path"] = path + captured["args"] = args + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "--version"], + credential=CredentialResult(api_key="k_abc", source_name="test"), + owner="acme", + repo="prod", + ) + + assert code == 0 + assert captured["path"] == "/usr/bin/mvn" + assert captured["args"] == ["--version"] + # No temp settings dir left behind. + assert not list(_home.glob("**/settings.xml")) + + +def test_run_provisions_prepends_s_and_cleans_up(_home, monkeypatch): + """Auth path: provisions settings.xml, prepends -s, cleans up temp dir.""" + config.set_plugin( + "maven", + config.PluginEntry(owner="acme", repo="prod"), + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + + captured = {} + + def _fake_run_process(path, args, env): + captured["path"] = path + captured["args"] = args + # Settings file must still exist while the child runs. + captured["settings_exists"] = Path(args[1]).exists() + return 7 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k_abc", source_name="test"), + owner="acme", + repo="prod", + ) + + assert code == 7 + assert captured["args"][0] == "-s" + assert captured["args"][-1] == "install" + assert captured["settings_exists"] is True + # Temp dir cleaned up after the child exits. + assert not Path(captured["args"][1]).exists() + + +def test_run_empty_command_returns_nonzero(_home): + """exec with no command is a usage error.""" + assert runner.run([], credential=None) != 0 + + +def test_run_unmatched_command_runs_generically(_home, monkeypatch): + """A command with no matching plugin runs as-is, with no provisioning.""" + monkeypatch.setattr( + runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/whoami" + ) + captured = {} + + def _fake_run_process(path, args, env): + captured["path"] = path + captured["args"] = args + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run(["whoami"], credential=None) + assert code == 0 + assert captured["path"] == "/usr/bin/whoami" + assert captured["args"] == [] + assert not list(_home.glob("**/settings.xml")) + + +def test_run_provision_failure_is_clean_no_traceback(_home, monkeypatch): + """A provisioning error returns a non-zero code, not a traceback.""" + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + + class _BoomPlugin: + name = "maven" + binary_name = "mvn" + + def skip_auth_args(self): + return [] + + def skip_provision_reason(self, _args): + return None + + def provision(self, *_a, **_k): + raise OSError("disk full") + + monkeypatch.setattr(plugin, "get_by_binary", lambda _b: _BoomPlugin()) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + ) + assert code != 0 + + +def test_maven_provision_cleans_temp_dir_on_failure(monkeypatch, tmp_path): + """If writing settings.xml fails, provision removes its temp dir and re-raises.""" + leak = tmp_path / "leak-dir" + + def _fake_mkdtemp(*_a, **_k): + leak.mkdir() + return str(leak) + + def _boom(**_k): + raise OSError("boom") + + monkeypatch.setattr(maven.tempfile, "mkdtemp", _fake_mkdtemp) + monkeypatch.setattr(maven, "build_settings_xml", _boom) + + with pytest.raises(OSError): + maven.MavenPlugin().provision( + config.PluginEntry(owner="acme", repo="prod"), "tok", [] + ) + assert not leak.exists() + + +def test_run_no_token_warns_but_proceeds(_home, monkeypatch): + """No credential on an auth path still runs (public repos), with a warning.""" + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["args"] = args + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run(["mvn", "install"], credential=None) + assert code == 0 + assert captured["args"][0] == "-s" + + +def test_run_explicit_org_repo_override_stored_entry(_home, monkeypatch): + """Explicit --org/--repo on exec take precedence over the stored binding.""" + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["settings"] = Path(args[1]).read_text(encoding="utf-8") + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + owner="other", + repo="dev", + ) + assert code == 0 + assert "/basic/other/dev/maven/" in captured["settings"] + assert "/basic/acme/prod/maven/" not in captured["settings"] + + +def test_run_org_override_alone_does_not_inherit_the_stored_repo(_home, monkeypatch): + """A different --org invalidates the rest of the stored binding. + + Keeping the previous org's repo slug would resolve every dependency from a + repository the user never asked for; failing loudly is the only honest + outcome when the new org's repo is unknown. + """ + config.set_plugin( + "maven", + config.PluginEntry(owner="acme", repo="prod", cdn_host="dl-acme.example.com"), + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + ran = [] + monkeypatch.setattr(runner, "_run_process", lambda *args: ran.append(args) or 0) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + owner="other-org", + ) + + assert code == 2 + assert not ran + + +def test_run_org_override_drops_the_previous_orgs_custom_domain(_home, monkeypatch): + """Resolved hosts belong to the org they were discovered for.""" + config.set_plugin( + "maven", + config.PluginEntry(owner="acme", repo="prod", cdn_host="dl-acme.example.com"), + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["settings"] = Path(args[1]).read_text(encoding="utf-8") + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + owner="other-org", + repo="dev", + ) + + assert code == 0 + assert "dl-acme.example.com" not in captured["settings"] + assert "https://dl.cloudsmith.io/basic/other-org/dev/maven/" in captured["settings"] + + +def test_run_repo_override_drops_a_repository_scoped_host(_home, monkeypatch): + """A repository-scoped host serves only the repo it was discovered for.""" + config.set_plugin( + "maven", + config.PluginEntry( + owner="acme", + repo="prod", + cdn_host="dl-prod.acme.com", + cdn_scope="repository", + ), + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["settings"] = Path(args[1]).read_text(encoding="utf-8") + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + repo="dev", + ) + + assert code == 0 + assert "dl-prod.acme.com" not in captured["settings"] + assert "https://dl.cloudsmith.io/basic/acme/dev/maven/" in captured["settings"] + + +def test_drop_repository_scoped_hosts_resets_upload_host_and_scope(_home): + """The upload_host/upload_scope half is unobservable through `exec`. + + The runner only injects cdn_host into settings.xml, so a bug confined to + the upload_host/upload_scope branch of _drop_repository_scoped_hosts + would go unnoticed without exercising it directly. + """ + entry = config.PluginEntry( + owner="acme", + repo="prod", + cdn_host="dl.acme.com", + upload_host="mvn-prod.acme.com", + upload_scope="repository", + ) + + result = _drop_repository_scoped_hosts(entry) + + assert result.upload_host == config.default_upload_host() + assert result.upload_scope == "organization" + # Only the repository-scoped side (upload) is reset. + assert result.cdn_host == "dl.acme.com" + assert result.cdn_scope == "organization" + + +def test_drop_repository_scoped_hosts_leaves_organisation_scoped_upload(_home): + """An organisation-scoped upload host is not repository-bound, so it stays.""" + entry = config.PluginEntry( + owner="acme", + repo="prod", + upload_host="mvn.acme.com", + upload_scope="organization", + ) + + assert _drop_repository_scoped_hosts(entry) == entry + + +def test_run_repo_override_matching_current_repo_keeps_repository_scoped_host( + _home, monkeypatch +): + """An explicit --repo equal to the stored repo must not trigger a reset. + + _resolve_entry only resets repository-scoped hosts when --repo differs + from the stored one; passing the same repo explicitly must leave a + repository-scoped host untouched. + """ + config.set_plugin( + "maven", + config.PluginEntry( + owner="acme", + repo="prod", + cdn_host="dl-prod.acme.com", + cdn_scope="repository", + ), + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["settings"] = Path(args[1]).read_text(encoding="utf-8") + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + repo="prod", + ) + + assert code == 0 + assert "https://dl-prod.acme.com/basic/maven/" in captured["settings"] + + +def test_run_repo_override_keeps_an_organisation_scoped_host(_home, monkeypatch): + """An organisation-wide custom domain serves every repo in the org.""" + config.set_plugin( + "maven", + config.PluginEntry(owner="acme", repo="prod", cdn_host="dl.acme.com"), + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["settings"] = Path(args[1]).read_text(encoding="utf-8") + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + repo="dev", + ) + + assert "https://dl.acme.com/basic/dev/maven/" in captured["settings"] + + +def test_run_uses_the_persisted_scope_offline(_home, monkeypatch): + """The wrapped run builds a repository-scoped URL with no lookup.""" + config.set_plugin( + "maven", + config.PluginEntry( + owner="acme", + repo="prod", + cdn_host="dl-prod.acme.com", + cdn_scope="repository", + ), + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["settings"] = Path(args[1]).read_text(encoding="utf-8") + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + ) + + assert code == 0 + assert "https://dl-prod.acme.com/basic/maven/" in captured["settings"] + + +def test_run_binary_not_found_returns_nonzero(_home, monkeypatch): + """When the real binary cannot be resolved, run returns non-zero.""" + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: None) + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + owner="acme", + repo="prod", + ) + assert code != 0 + + +@pytest.mark.parametrize("owner,repo", [(None, None), ("acme", None), (None, "prod")]) +def test_run_unconfigured_plugin_is_a_clear_error( + _home, monkeypatch, capsys, owner, repo +): + """No stored binding and no complete --org/--repo pair must not provision. + + Provisioning would inject a malformed URL like /basic///maven/ and shadow + the user's own settings.xml; the customer gets baffling Maven errors. + """ + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + ran = {} + monkeypatch.setattr( + runner, "_run_process", lambda *_a, **_k: ran.setdefault("ran", True) + ) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + owner=owner, + repo=repo, + ) + + assert code != 0 + assert "ran" not in ran + stderr = capsys.readouterr().err + assert "credential-helper install maven" in stderr + assert not list(_home.glob("**/settings.xml")) + + +@pytest.mark.parametrize( + "settings_args", + [ + ["-s", "mine.xml"], + ["--settings", "mine.xml"], + ["--settings=mine.xml"], + ["-smine.xml"], + ], +) +def test_run_user_settings_file_wins_over_injection( + _home, monkeypatch, capsys, settings_args +): + """A user-supplied -s/--settings runs unwrapped, with a warning. + + Prepending our own -s as well would silently shadow the user's file + (Maven takes the first occurrence). + """ + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["args"] = args + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", *settings_args, "install"], + credential=CredentialResult(api_key="k", source_name="t"), + ) + + assert code == 0 + assert captured["args"] == [*settings_args, "install"] + assert "settings" in capsys.readouterr().err + assert not list(_home.glob("**/settings.xml")) + + +def test_run_default_org_repo_fill_in_when_unconfigured(_home, monkeypatch): + """Environment-sourced org/repo apply when no binding is stored.""" + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["settings"] = Path(args[1]).read_text(encoding="utf-8") + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + default_owner="acme", + default_repo="prod", + ) + assert code == 0 + assert "/basic/acme/prod/maven/" in captured["settings"] + + +def test_run_default_org_repo_do_not_override_stored_binding(_home, monkeypatch): + """Environment-sourced org/repo must not silently override a stored binding. + + OIDC users keep CLOUDSMITH_ORG exported permanently; letting it override + the binding points wrapped runs at a repository that does not exist. + """ + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["settings"] = Path(args[1]).read_text(encoding="utf-8") + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k", source_name="t"), + default_owner="env-org", + default_repo="env-repo", + ) + assert code == 0 + assert "/basic/acme/prod/maven/" in captured["settings"] + assert "env-org" not in captured["settings"] + + +@pytest.mark.parametrize("command", ["./mvnw", "/usr/local/bin/mvn", "bin/mvn"]) +def test_run_matches_a_path_qualified_command(_home, monkeypatch, command): + """A path-qualified command must still be authenticated. + + The plugin is named after the binary, so matching the raw argv entry ran + `./mvnw` — the dominant modern invocation — with no credentials at all and + no warning, failing every private dependency with a 401. + """ + config.set_plugin("maven", config.PluginEntry(owner="acme", repo="prod")) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + captured = {} + + def _fake_run_process(path, args, env): + captured["args"] = args + return 0 + + monkeypatch.setattr(runner, "_run_process", _fake_run_process) + + runner.run( + [command, "install"], + credential=CredentialResult(api_key="k_abc", source_name="test"), + ) + + assert captured["args"][0] == "-s" + + +def test_run_translates_a_signal_death_to_the_shell_convention(_home, monkeypatch): + """A signal-killed child must not exit with a truncated negative status. + + subprocess reports a signal death as a negative returncode; handing that to + sys.exit truncates it to the low 8 bits, so SIGKILL surfaced as 247 and CI + branching on 137 (OOM) misread it as an ordinary build failure. + """ + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + + class _Completed: + returncode = -9 + + monkeypatch.setattr(runner.subprocess, "run", lambda *_a, **_k: _Completed()) + + assert runner.run(["mvn", "--version"]) == 137 + + +def test_run_survives_a_malformed_package_managers_ini(_home, monkeypatch): + """A hand-edited binding file must not break every wrapped run. + + The shim intercepts every `mvn` on the machine, so a parse error escaping + here made Maven unusable machine-wide rather than merely unconfigured. + """ + config.config_path().write_text( + "owner = acme\n[package-manager:maven]\n", encoding="utf-8" + ) + monkeypatch.setattr(runner, "resolve_real_binary", lambda *_a, **_k: "/usr/bin/mvn") + + code = runner.run( + ["mvn", "install"], + credential=CredentialResult(api_key="k_abc", source_name="test"), + ) + + assert code == 2 diff --git a/cloudsmith_cli/cli/tests/commands/test_default_domains.py b/cloudsmith_cli/cli/tests/commands/test_default_domains.py index 20010697..ebff5ace 100644 --- a/cloudsmith_cli/cli/tests/commands/test_default_domains.py +++ b/cloudsmith_cli/cli/tests/commands/test_default_domains.py @@ -8,7 +8,11 @@ from ....credential_helpers.default_domains import ( BUILTIN_DOMAINS, DefaultDomain, + DomainScope, DomainType, + default_host, + default_host_for_type, + domain_scope, load_default_domains, untrusted_config_declares_domains, ) @@ -187,3 +191,73 @@ def test_untrusted_cwd_config_is_not_honoured(tmp_path, monkeypatch): assert all(domain.host != "evil.example.com" for domain in domains) assert untrusted_config_declares_domains() is True + + +def test_default_host_resolves_backend_kind(no_trusted_config): + """With no override the table is the built-in one.""" + assert default_host(BackendKind.MAVEN) == "maven.cloudsmith.io" + assert default_host(BackendKind.PYTHON) == "python.cloudsmith.io" + + +def test_default_host_rejects_kind_without_dedicated_host(no_trusted_config): + """Formats served only via the CDN have no dedicated host to resolve.""" + with pytest.raises(ValueError): + default_host(BackendKind.DEB) + + +def test_default_host_for_type_resolves_download_and_upload(no_trusted_config): + """The download/upload hosts are looked up by type, not by constant.""" + assert default_host_for_type(DomainType.DOWNLOAD) == "dl.cloudsmith.io" + assert default_host_for_type(DomainType.UPLOAD) == "upload.cloudsmith.io" + + +def test_default_host_for_type_rejects_ambiguous_native_api(no_trusted_config): + """NATIVE_API covers many hosts, so there is no single one to return.""" + with pytest.raises(ValueError): + default_host_for_type(DomainType.NATIVE_API) + + +def test_default_hosts_honour_a_config_override(tmp_path, monkeypatch): + """default_host/_for_type resolve against the override, not the builtins.""" + (tmp_path / "config.ini").write_text( + "[domains]\n" + "cdn.internal.example.com = download\n" + "mvn.internal.example.com = maven\n", + encoding="utf-8", + ) + monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"]) + monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", [str(tmp_path)]) + + assert default_host_for_type(DomainType.DOWNLOAD) == "cdn.internal.example.com" + assert default_host(BackendKind.MAVEN) == "mvn.internal.example.com" + + +def test_default_host_raises_when_the_override_omits_it(tmp_path, monkeypatch): + """An override that omits a kind must not resolve to the public host. + + A declared table replaces the built-ins wholesale, so falling back would + hand a dedicated deployment `maven.cloudsmith.io` - publishing its + artifacts, and its token, to a host the operator never listed. + """ + (tmp_path / "config.ini").write_text( + "[domains]\ncdn.internal.example.com = download\n", encoding="utf-8" + ) + monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"]) + monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", [str(tmp_path)]) + + with pytest.raises(ValueError): + default_host(BackendKind.MAVEN) + + +@pytest.mark.parametrize( + "value,expected", + [ + ("repository", DomainScope.REPOSITORY), + ("organization", DomainScope.ORGANIZATION), + (None, DomainScope.ORGANIZATION), + ("nonsense", DomainScope.ORGANIZATION), + ], +) +def test_domain_scope_degrades_to_organization(value, expected): + """A hand-edited package-managers.ini must not break every wrapped run.""" + assert domain_scope(value) is expected diff --git a/cloudsmith_cli/cli/utils.py b/cloudsmith_cli/cli/utils.py index 1db72b85..48a8305f 100644 --- a/cloudsmith_cli/cli/utils.py +++ b/cloudsmith_cli/cli/utils.py @@ -15,6 +15,18 @@ from .table import make_table +def normalise_slug(value): + """Return *value* as a usable org/repo slug, or None when it names nothing. + + click passes an environment variable through verbatim, so a padded + ``CLOUDSMITH_ORG``/``CLOUDSMITH_REPO`` would otherwise be interpolated into + a URL as a slug and 404 every request built from it. + """ + if not value: + return None + return value.strip() or None + + def make_user_agent(prefix=None): """Get a suitable user agent for identifying the CLI process.""" prefix = (prefix or platform.platform(terse=1)).strip().lower() diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index de0f9d26..f66844e3 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -8,6 +8,7 @@ import logging from .custom_domains import get_custom_domains, get_format_domains +from .default_domains import DomainScope, load_default_domains logger = logging.getLogger(__name__) @@ -47,6 +48,53 @@ def extract_hostname(url): return hostname +def is_standard_cloudsmith_host(url): + """Return True if *url*'s host is a standard Cloudsmith host. + + Standard hosts are ``cloudsmith.io``/``cloudsmith.com`` and their + subdomains. Anything else is treated as a custom domain. + """ + hostname = extract_hostname(url) + return ( + hostname in ("cloudsmith.io", "cloudsmith.com") + or hostname.endswith(".cloudsmith.io") + or hostname.endswith(".cloudsmith.com") + ) + + +def is_default_host(url): + """Return True if *url*'s host is one of the effective default hosts. + + The effective table is the built-in ``*.cloudsmith.io`` hosts, replaced + wholesale by a trusted ``[domains]`` override when a deployment declares + one (see :func:`default_domains.load_default_domains`). Either way, a + match here is the deployment's own service host - the equivalent of + ``dl.cloudsmith.io`` - not a genuinely discovered custom domain. Reads + ``config.ini`` from disk (no API call), matching what callers on the + credential-injection path already do via ``default_cdn_host()``. + """ + hostname = extract_hostname(url) + return any(domain.host.lower() == hostname for domain in load_default_domains()) + + +def repo_path_segment(owner, repo, host, scope=DomainScope.ORGANIZATION): + """Return the path segment identifying the repository in a Cloudsmith URL. + + A default host - built-in or declared in a trusted ``[domains]`` table - + includes the org (``/``), the same as any standard + ``*.cloudsmith.io`` host. A genuinely discovered custom domain is bound + to a single org, so the org is omitted (````); one bound to a single + repository identifies that too, so nothing remains (``""``). This rule is + Cloudsmith-wide, not format-specific. A default host is never a custom + domain, so the scope cannot apply to it. + """ + if is_standard_cloudsmith_host(host) or is_default_host(host): + return f"{owner}/{repo}" + if scope is DomainScope.REPOSITORY: + return "" + return repo + + def is_cloudsmith_domain( url, credential=None, api_host=None, backend_kind=None, org=None ): @@ -74,11 +122,7 @@ def is_cloudsmith_domain( return False # Standard Cloudsmith domains — no auth needed, always match regardless of backend_kind - if ( - hostname in ("cloudsmith.io", "cloudsmith.com") - or hostname.endswith(".cloudsmith.io") - or hostname.endswith(".cloudsmith.com") - ): + if is_standard_cloudsmith_host(hostname): return True # Custom domains require org + auth diff --git a/cloudsmith_cli/credential_helpers/custom_domains.py b/cloudsmith_cli/credential_helpers/custom_domains.py index cec75ee6..b99fe3b3 100644 --- a/cloudsmith_cli/credential_helpers/custom_domains.py +++ b/cloudsmith_cli/credential_helpers/custom_domains.py @@ -24,7 +24,7 @@ from ..core.api.orgs import list_custom_domains from ..core.cache_utils import atomic_write_json from ..core.credentials.models import CredentialResult -from .default_domains import DomainType, domain_type_from_server +from .default_domains import DomainScope, DomainType, domain_type_from_server logger = logging.getLogger(__name__) @@ -52,6 +52,15 @@ def is_active(self) -> bool: """Whether this domain can serve traffic at all.""" return self.enabled and self.validated + @property + def scope(self) -> DomainScope: + """What this domain is bound to, as its URLs are built from. + + A domain serving a single repository identifies both the organisation + and the repository, so neither appears in a path built under it. + """ + return DomainScope.REPOSITORY if self.repository else DomainScope.ORGANIZATION + def serves_repository(self, repository: str | None) -> bool: """Whether this domain may be used for `repository`.""" return not self.repository or self.repository == repository diff --git a/cloudsmith_cli/credential_helpers/default_domains.py b/cloudsmith_cli/credential_helpers/default_domains.py index b56b7cf3..71422ad7 100644 --- a/cloudsmith_cli/credential_helpers/default_domains.py +++ b/cloudsmith_cli/credential_helpers/default_domains.py @@ -49,6 +49,30 @@ class DomainType(str, Enum): NATIVE_API = "native_api" +class DomainScope(str, Enum): + """What a custom domain is bound to. + + A custom domain always belongs to one organisation; one that additionally + serves a single repository identifies that repository too, so neither + appears in its URLs. + """ + + ORGANIZATION = "organization" + REPOSITORY = "repository" + + +def domain_scope(value: str | None) -> DomainScope: + """Resolve a persisted scope string, defaulting to organisation. + + An unrecognised value degrades rather than raising: a hand-edited + ``package-managers.ini`` must not break every wrapped run. + """ + try: + return DomainScope(value) + except ValueError: + return DomainScope.ORGANIZATION + + SERVER_DOMAIN_TYPES: dict[int, DomainType] = { 0: DomainType.DOWNLOAD, 1: DomainType.UPLOAD, @@ -132,6 +156,30 @@ def format_label(self) -> str | None: } +def _host_for_backend_kind( + domains: tuple[DefaultDomain, ...] | list[DefaultDomain], backend_kind: int +) -> str | None: + """Return the first host in `domains` serving `backend_kind`, if any.""" + for domain in domains: + if domain.backend_kind == backend_kind: + return domain.host + return None + + +def _host_for_type( + domains: tuple[DefaultDomain, ...] | list[DefaultDomain], domain_type: DomainType +) -> str | None: + """Return the first host in `domains` of `domain_type`, if any.""" + if domain_type is DomainType.NATIVE_API: + raise ValueError( + "NATIVE_API is served by many hosts; resolve it with default_host()" + ) + for domain in domains: + if domain.domain_type is domain_type: + return domain.host + return None + + def _resolve_backend_kind(label: str) -> int | None: """Resolve a config-declared label to a BackendKind member, if any.""" if not label: @@ -289,6 +337,32 @@ def load_default_domains(config_path: Path | str | None = None) -> list[DefaultD return domains +def default_host(backend_kind: int) -> str: + """Return the host for `backend_kind`, honouring a trusted override. + + A declared ``[domains]`` table replaces the built-ins wholesale, so a kind + it omits raises rather than resolving to the ``*.cloudsmith.io`` host the + operator deliberately did not list - handing one back would publish a + dedicated deployment's artifacts, and its token, to the public service. + """ + host = _host_for_backend_kind(load_default_domains(), backend_kind) + if host is None: + raise ValueError(f"No Cloudsmith host for backend kind {backend_kind}") + return host + + +def default_host_for_type(domain_type: DomainType) -> str: + """Return the host of `domain_type`, honouring a trusted override. + + Raises for a type the effective table does not serve, for the reasons + :func:`default_host` gives. + """ + host = _host_for_type(load_default_domains(), domain_type) + if host is None: + raise ValueError(f"No Cloudsmith host of type {domain_type.value}") + return host + + def untrusted_config_declares_domains() -> bool: """True if a directory-relative config.ini declares a [domains] section. diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index 5ad9356e..04db9fd6 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -11,14 +11,19 @@ import json import logging import os -import sys from pathlib import Path from ...core.cache_utils import merge_json_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 +from ..launchers import ( + cloudsmith_command, + is_on_path, + remove_launcher, + resolve_bin_dir, + write_launcher, +) logger = logging.getLogger(__name__) @@ -51,28 +56,12 @@ class DockerInstaller: """ 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" - @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 docker' - return cls.TARGET_CMD - def install( self, *, @@ -208,7 +197,9 @@ def mutate(config: dict) -> None: # Real install launcher_path = write_launcher( - target_dir, self.LAUNCHER_NAME, self._resolve_target_cmd() + target_dir, + self.LAUNCHER_NAME, + cloudsmith_command("credential-helper", "docker"), ) actions.append(f"wrote launcher {launcher_path}") diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index acf978ef..541d8b98 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -24,11 +24,33 @@ def _is_windows() -> bool: return os.name == "nt" +def is_frozen() -> bool: + """Return True when running from a frozen standalone build (PyInstaller).""" + return getattr(sys, "frozen", False) + + +def cloudsmith_command(*args: str) -> str: + """Return the ``cloudsmith`` command line a launcher forwards to. + + A pip/source install resolves the bare ``cloudsmith`` command via ``PATH``. + A frozen standalone build is not guaranteed to be on ``PATH`` under that + name, so it is addressed by its absolute executable instead — quoted, so a + directory containing spaces still execs correctly. + """ + executable = f'"{sys.executable}"' if is_frozen() else "cloudsmith" + return " ".join((executable, *args)) + + 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_filename(name: str) -> str: + """Return the launcher file name *name* is written under on this platform.""" + return _launcher_filename(name, windows=_is_windows()) + + def _launcher_content(target_cmd: str, *, windows: bool) -> str: """Return the launcher script body for the platform. @@ -101,7 +123,7 @@ def remove_launcher(bin_dir: Path, name: str) -> bool: bool ``True`` if a file was removed, ``False`` if no file was found. """ - target = Path(bin_dir) / _launcher_filename(name, windows=_is_windows()) + target = Path(bin_dir) / launcher_filename(name) if target.exists(): target.unlink() diff --git a/cloudsmith_cli/credential_helpers/maven/__init__.py b/cloudsmith_cli/credential_helpers/maven/__init__.py new file mode 100644 index 00000000..d444388a --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Maven credential support (shell-plugin installer).""" diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py new file mode 100644 index 00000000..8c997290 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -0,0 +1,395 @@ +# Copyright 2026 Cloudsmith Ltd +"""Installer for the Maven shell-plugin credential helper. + +Writes an ``mvn`` shim into the Cloudsmith shims dir, records the repo binding +and resolved hosts in the CLI config, and surfaces the ``distributionManagement`` +snippet needed for ``mvn deploy``. Download resolution works transparently once +the shims dir is on PATH (via ``credential-helper shell-init``); upload is opt-in. + +Maven uses two distinct endpoints, so two custom-domain kinds matter: +- **download** (dependency resolution) goes via the download CDN; its custom + domains carry ``backend_kind is None`` (the generic download domain). +- **upload** (``distributionManagement``) goes via the native Maven endpoint; + its custom domains carry ``BackendKind.MAVEN``. +""" + +from __future__ import annotations + +import logging +import shutil +from dataclasses import dataclass, replace +from xml.sax.saxutils import escape + +from ...core.api.exceptions import ApiException +from ...core.credentials.models import CredentialResult +from ...templates import render +from ..backends import BackendKind +from ..custom_domains import CustomDomain, get_custom_domains, order_by_precedence +from ..default_domains import DomainScope, DomainType +from ..launchers import is_frozen, is_on_path +from ..shellplugin import config +from ..shellplugin.maven import upload_url +from ..shellplugin.shims import remove_shim, shim_path, write_shim + +logger = logging.getLogger(__name__) + +_DISTRIBUTION_MANAGEMENT_TEMPLATE = "maven_distribution_management.xml.tmpl" + +_USER_SETTINGS_NOTE = ( + "note: wrapped mvn runs use an ephemeral settings.xml; your " + "~/.m2/settings.xml (mirrors, proxies, other servers) is not consulted. " + "Pass your own -s/--settings to mvn to bypass credential injection." +) + + +def _deploy_snippet( + owner: str, + repo: str, + upload_host: str, + registry_id: str, + scope: DomainScope = DomainScope.ORGANIZATION, +) -> str: + """Return the pom.xml distributionManagement snippet for opt-in deploy.""" + snippet = render( + _DISTRIBUTION_MANAGEMENT_TEMPLATE, + server_id=escape(registry_id), + url=escape(upload_url(owner, repo, upload_host, scope)), + ) + return ( + "To publish with `mvn deploy`, add this to your pom.xml " + "(the id must match the server id):\n" + snippet + "\n" + "note: `exec --org`/`--repo` overrides only retarget downloads; " + "`mvn deploy` still publishes to whatever distributionManagement " + "the pom names, written here for this install-time repository." + ) + + +@dataclass(frozen=True) +class ResolvedHosts: + """The hosts a binding resolves to, with what each one is bound to.""" + + cdn_host: str + upload_host: str + cdn_scope: DomainScope = DomainScope.ORGANIZATION + upload_scope: DomainScope = DomainScope.ORGANIZATION + + +def _is_eligible( + domain: CustomDomain, + backend_kind: int | None, + domain_type: DomainType, + repo: str | None, +) -> bool: + """True when *domain* can serve *repo* as a *domain_type* endpoint. + + The backend kind alone does not identify an endpoint: the download CDN and + the generic upload endpoint both carry ``backend_kind is None``, so matching + on it alone would let an upload domain be bound as the download host. A + domain bound to a different repository serves only that repository, so it + cannot back this binding at all. + """ + return ( + domain.backend_kind == backend_kind + and domain.domain_type is domain_type + and domain.is_active + and domain.serves_repository(repo) + ) + + +def _select_domain( + domains: list[CustomDomain], + backend_kind: int | None, + domain_type: DomainType, + repo: str | None, +) -> CustomDomain | None: + """Return the domain Cloudsmith would bind for this endpoint and *repo*. + + Candidates are ranked the way the server ranks overlapping domains — bound + to this repository first, then primary, then oldest — so the binding this + install records is the host Cloudsmith itself would serve, and two installs + of the same repository agree regardless of the order discovery returned. + """ + candidates = order_by_precedence( + [d for d in domains if _is_eligible(d, backend_kind, domain_type, repo)], repo + ) + return candidates[0] if candidates else None + + +def _discovered_domain(host: str, domains: list[CustomDomain]) -> CustomDomain | None: + """Return the discovered record for *host*, if discovery saw it.""" + for domain in domains: + if domain.host == host: + return domain + return None + + +def _cloudsmith_command_is_unresolvable() -> bool: + """True when `cloudsmith` cannot be resolved and this is not a frozen build. + + The shim execs ``cloudsmith exec -- mvn``, so an inactive venv makes every + wrapped ``mvn`` fail machine-wide, not just this shim. A frozen build + points the shim at ``sys.executable`` directly (see + :func:`launchers.cloudsmith_command`), so it does not depend on + `cloudsmith` being on PATH. + """ + if is_frozen(): + return False + return shutil.which("cloudsmith") is None + + +class MavenInstaller: + """Installs the Maven shell plugin (shim + config entry).""" + + BINARY_NAME = "mvn" + name = "maven" + summary = "Maven shell plugin for Cloudsmith repositories" + requires_repo = True + + def _discover_domains( + self, + *, + org: str | None, + credential: CredentialResult | None, + api_host: str | None, + refresh: bool, + actions: list[str], + ) -> list[CustomDomain]: + """Fetch the org's custom domains (best-effort; failure → WARNING + []). + + The lookup is strict so the failure reaches this boundary as an + ApiException rather than an empty list: an unreachable API must not + read as "this org has no custom domains" and silently bind the install + to the default hosts without saying so. Every failure mode the API + layer has lands there, transport errors included. + """ + if not (org and credential): + return [] + try: + return get_custom_domains( + org, + credential=credential, + api_host=api_host, + refresh=refresh, + strict=True, + ) + except ApiException as exc: + actions.append(f"WARNING: custom-domain discovery failed: {exc}") + return [] + + def _resolve_hosts( + self, + *, + domains_override: tuple[str, ...], + discover: bool, + org: str | None, + repo: str | None, + credential: CredentialResult | None, + api_host: str | None, + refresh: bool, + actions: list[str], + ) -> ResolvedHosts: + """Resolve the CDN/upload hosts from overrides, discovery, defaults.""" + discovered: list[CustomDomain] = [] + if discover: + discovered = self._discover_domains( + org=org, + credential=credential, + api_host=api_host, + refresh=refresh, + actions=actions, + ) + + cdn = _select_domain(discovered, None, DomainType.DOWNLOAD, repo) + upload = _select_domain( + discovered, BackendKind.MAVEN, DomainType.NATIVE_API, repo + ) + hosts = ResolvedHosts( + cdn_host=cdn.host if cdn else config.default_cdn_host(), + upload_host=upload.host if upload else config.default_upload_host(), + cdn_scope=cdn.scope if cdn else DomainScope.ORGANIZATION, + upload_scope=upload.scope if upload else DomainScope.ORGANIZATION, + ) + return self._apply_domain_overrides( + domains_override=domains_override, + discovered=discovered, + repo=repo, + hosts=hosts, + actions=actions, + ) + + def _apply_domain_overrides( + self, + *, + domains_override: tuple[str, ...], + discovered: list[CustomDomain], + repo: str | None, + hosts: ResolvedHosts, + actions: list[str], + ) -> ResolvedHosts: + """Apply ``--domain`` values to the resolved hosts. + + Maven speaks to two endpoints, so each value is routed by the record + discovery reports for it: a native Maven domain replaces the upload + host, anything else the download host. A value discovery never saw + carries nothing to tell the two apart, so it is taken as the download + host and said so, rather than silently binding the wrong endpoint. A + domain bound to another repository is refused outright, the same rule + automatic selection applies. Only one host per role is usable, so a + value superseded by a later one is reported rather than dropped in + silence. + """ + for host in domains_override: + record = _discovered_domain(host, discovered) + if record is not None and not record.serves_repository(repo): + actions.append( + f"WARNING: --domain {host} is bound to repository " + f"{record.repository}, so it cannot serve {repo} — ignored" + ) + continue + if record is None: + actions.append( + f"note: --domain {host} was not discovered, so it is taken " + "as the download host; routing one to the upload endpoint " + "needs discovery to report it as a native Maven domain" + ) + scope = record.scope if record else DomainScope.ORGANIZATION + if record is not None and record.backend_kind == BackendKind.MAVEN: + superseded, role = hosts.upload_host, "upload" + hosts = replace(hosts, upload_host=host, upload_scope=scope) + else: + superseded, role = hosts.cdn_host, "download" + hosts = replace(hosts, cdn_host=host, cdn_scope=scope) + if superseded != host and superseded in domains_override: + actions.append( + f"WARNING: --domain {superseded} superseded by {host} " + f"as the {role} host" + ) + return hosts + + def install( + self, + *, + domains: tuple[str, ...] = (), + discover: bool = True, + refresh: bool = False, + org: str | None = None, + repo: str | None = None, + registry_id: str = config.DEFAULT_REGISTRY_ID, + credential: CredentialResult | None = None, + api_host: str | None = None, + dry_run: bool = False, + ) -> list[str]: + """Install the Maven shell plugin; return human-readable actions.""" + owner = org + actions: list[str] = [] + + hosts = self._resolve_hosts( + domains_override=domains, + discover=discover, + org=owner, + repo=repo, + credential=credential, + api_host=api_host, + refresh=refresh, + actions=actions, + ) + + shims_dir = config.shims_dir() + shim = shim_path(shims_dir, self.BINARY_NAME) + binding = ( + f"maven for {owner}/{repo} " + f"(download {hosts.cdn_host}, upload {hosts.upload_host})" + ) + + if dry_run: + actions.append(f"would write shim {shim}") + actions.append(f"would configure {binding}") + else: + # The binding is persisted first: the shim intercepts every `mvn` + # on the machine and refuses to run one it has no binding for, so a + # shim written ahead of a failed set_plugin would leave Maven + # unusable rather than merely uninstalled. + config.set_plugin( + self.name, + config.PluginEntry( + owner=owner or "", + repo=repo or "", + cdn_host=hosts.cdn_host, + cdn_scope=hosts.cdn_scope.value, + upload_host=hosts.upload_host, + upload_scope=hosts.upload_scope.value, + registry_id=registry_id, + ), + ) + actions.append(f"configured {binding}") + + write_shim(shims_dir, self.BINARY_NAME) + actions.append(f"wrote shim {shim}") + + if not is_on_path(shims_dir): + actions.append( + f"WARNING: {shims_dir} is not on PATH — add it with " + '`eval "$(cloudsmith credential-helper shell-init)"`' + ) + + if _cloudsmith_command_is_unresolvable(): + actions.append( + "WARNING: the `cloudsmith` command is not on PATH — every " + "wrapped mvn run will fail until it is; activate the " + "environment it is installed in" + ) + + actions.append(_USER_SETTINGS_NOTE) + actions.append( + _deploy_snippet( + owner or "", + repo or "", + hosts.upload_host, + registry_id, + hosts.upload_scope, + ) + ) + return actions + + def uninstall(self, *, dry_run: bool = False) -> list[str]: + """Remove the Maven shim and drop its config entry.""" + shims_dir = config.shims_dir() + shim = shim_path(shims_dir, self.BINARY_NAME) + actions: list[str] = [] + + if dry_run: + if shim.exists(): + actions.append(f"would remove shim {shim}") + else: + actions.append(f"shim not found at {shim} (nothing to remove)") + if config.get_plugin(self.name) is not None: + actions.append("would remove maven from the package-manager config") + else: + actions.append("maven not configured (nothing to remove)") + return actions + + if remove_shim(shims_dir, self.BINARY_NAME): + actions.append(f"removed shim {shim}") + else: + actions.append(f"shim not found at {shim} (nothing to remove)") + + if config.remove_plugin(self.name): + actions.append("removed maven from the package-manager config") + else: + actions.append("maven not configured (nothing to remove)") + return actions + + def status(self) -> dict: + """Return shim path (str|None) and configured hosts for `list`.""" + shim = shim_path(config.shims_dir(), self.BINARY_NAME) + launcher = str(shim) if shim.exists() else None + + entry = config.get_plugin(self.name) + hosts: list[str] = [] + if entry is not None: + hosts = [ + f"{entry.owner}/{entry.repo}", + f"download:{entry.cdn_host}", + f"upload:{entry.upload_host}", + ] + return {"launcher": launcher, "hosts": hosts} diff --git a/cloudsmith_cli/credential_helpers/shellplugin/__init__.py b/cloudsmith_cli/credential_helpers/shellplugin/__init__.py new file mode 100644 index 00000000..b19edfa3 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Shell-plugin credential support (shim + exec) for package managers like Maven.""" diff --git a/cloudsmith_cli/credential_helpers/shellplugin/config.py b/cloudsmith_cli/credential_helpers/shellplugin/config.py new file mode 100644 index 00000000..a558e459 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/config.py @@ -0,0 +1,170 @@ +# Copyright 2026 Cloudsmith Ltd +"""Persistent state for shell-plugin credential helpers. + +Records which package-manager formats are enabled and the org/repo + resolved +hosts each is bound to, as ``[package-manager:]`` sections in +``package-managers.ini`` inside the CLI config directory (alongside +``config.ini`` / ``credentials.ini``). +""" + +from __future__ import annotations + +import configparser +import logging +import secrets +from dataclasses import asdict, dataclass, field +from pathlib import Path + +import click + +from ...cli.config import get_default_config_path +from ..backends import BackendKind +from ..default_domains import ( + DomainScope, + DomainType, + default_host, + default_host_for_type, +) + +DEFAULT_REGISTRY_ID = "cloudsmith" + +_SECTION_PREFIX = "package-manager:" + +logger = logging.getLogger(__name__) + + +def new_download_id() -> str: + """Mint the unguessable id the injected download repository is keyed by. + + Maven matches a ````'s credentials to a repository by id alone, so + a fixed, guessable id would let any checked-out ``pom.xml`` declaring a + repository under it receive the token. Minting it per install rather than + per invocation keeps it unguessable while staying stable across runs, which + Maven's local-repository origin tracking requires: a new id every run makes + every cached artifact read as coming from an unknown repository, so builds + re-download everything and offline builds fail outright. + """ + return f"cloudsmith-{secrets.token_hex(8)}" + + +def default_cdn_host() -> str: + """Return the download-CDN host, honouring a trusted [domains] override. + + Resolved per call rather than at import: a dedicated or on-premise + deployment replaces the domain table in its ``config.ini``, and a constant + frozen from the built-in table would pin every binding to + ``*.cloudsmith.io``. + """ + return default_host_for_type(DomainType.DOWNLOAD) + + +def default_upload_host() -> str: + """Return the native Maven upload host, honouring the same override.""" + return default_host(BackendKind.MAVEN) + + +@dataclass(frozen=True) +class PluginEntry: + """A single enabled shell-plugin's binding (org/repo + resolved hosts).""" + + owner: str + repo: str + cdn_host: str = field(default_factory=default_cdn_host) + cdn_scope: str = DomainScope.ORGANIZATION.value + upload_host: str = field(default_factory=default_upload_host) + upload_scope: str = DomainScope.ORGANIZATION.value + registry_id: str = DEFAULT_REGISTRY_ID + download_id: str = field(default_factory=new_download_id) + + @classmethod + def from_dict(cls, data) -> PluginEntry: + """Build an entry from a config section, filling defaults.""" + return cls( + owner=data.get("owner", ""), + repo=data.get("repo", ""), + cdn_host=data.get("cdn_host") or default_cdn_host(), + cdn_scope=data.get("cdn_scope") or DomainScope.ORGANIZATION.value, + upload_host=data.get("upload_host") or default_upload_host(), + upload_scope=data.get("upload_scope") or DomainScope.ORGANIZATION.value, + registry_id=data.get("registry_id") or DEFAULT_REGISTRY_ID, + download_id=data.get("download_id") or new_download_id(), + ) + + def to_dict(self) -> dict: + """Return the entry as a flat string mapping for the INI section.""" + return asdict(self) + + +def config_path() -> Path: + """Return the path to ``package-managers.ini`` in the CLI config dir.""" + return Path(get_default_config_path()) / "package-managers.ini" + + +def shims_dir() -> Path: + """Return the directory that holds package-manager shims on PATH.""" + return Path(get_default_config_path()) / "shims" + + +def _read() -> configparser.ConfigParser: + """Return the parsed config, or an empty one when it cannot be read. + + A hand-edited ``package-managers.ini`` must not break every wrapped run: + the shim intercepts every ``mvn`` on the machine, so letting a parse error + escape would make Maven unusable machine-wide rather than merely + unconfigured. An unreadable file reads as no binding, which the runner + already reports with the command needed to fix it. + """ + parser = configparser.ConfigParser(interpolation=None) + path = config_path() + if not path.exists(): + return parser + try: + parser.read(path, encoding="utf-8") + except (OSError, UnicodeDecodeError, configparser.Error) as exc: + logger.warning("Ignoring unreadable %s: %s", path, exc) + return configparser.ConfigParser(interpolation=None) + return parser + + +def _write(parser: configparser.ConfigParser) -> None: + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + with click.open_file(str(path), "w") as handle: + parser.write(handle) + + +def load_plugins() -> dict[str, PluginEntry]: + """Return the enabled plugins keyed by format name (``{}`` when none).""" + parser = _read() + return { + section[len(_SECTION_PREFIX) :]: PluginEntry.from_dict(parser[section]) + for section in parser.sections() + if section.startswith(_SECTION_PREFIX) + } + + +def get_plugin(name: str) -> PluginEntry | None: + """Return the stored entry for *name*, or ``None`` when not enabled.""" + parser = _read() + section = _SECTION_PREFIX + name + if not parser.has_section(section): + return None + return PluginEntry.from_dict(parser[section]) + + +def set_plugin(name: str, entry: PluginEntry) -> None: + """Record (or replace) the entry for *name* and persist.""" + parser = _read() + parser[_SECTION_PREFIX + name] = entry.to_dict() + _write(parser) + + +def remove_plugin(name: str) -> bool: + """Drop the entry for *name*; return True if one was removed.""" + parser = _read() + section = _SECTION_PREFIX + name + if not parser.has_section(section): + return False + parser.remove_section(section) + _write(parser) + return True diff --git a/cloudsmith_cli/credential_helpers/shellplugin/maven.py b/cloudsmith_cli/credential_helpers/shellplugin/maven.py new file mode 100644 index 00000000..f565bedf --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/maven.py @@ -0,0 +1,227 @@ +# Copyright 2026 Cloudsmith Ltd +"""Maven shell plugin. + +Maven has no credential helper, so we inject an ephemeral ``settings.xml`` via +``mvn -s`` for the duration of a single invocation. The file carries an +active profile, authored entirely by us, whose repositories point at the +Cloudsmith download CDN and whose matching ```` is keyed by the +unguessable id minted for the binding at install time — so dependency +resolution works with no ``pom.xml`` edits, and a repository a ``pom.xml`` +declares under a guessed id can never receive the token. The id is stable +across runs because Maven's local repository records which repository id an +artifact came from, so a new one each run would invalidate the whole cache and +break offline builds. Maven matches ```` credentials by id alone, with +no host check, so the stable, guessable ``registry_id`` server that matches +the user's ``distributionManagement`` is added only for deploy invocations, +which is the only case that needs it. +""" + +from __future__ import annotations + +import os +import shutil +import tempfile +from xml.sax.saxutils import escape + +from ...templates import render +from ..common import repo_path_segment +from ..default_domains import DomainScope, domain_scope +from .config import PluginEntry +from .plugin import ProvisionResult + +_SKIP_AUTH_ARGS = ["--help", "-h", "--version", "-v", "help"] +_USER_SETTINGS_ARGS = ("-s", "--settings", "-settings") +_SETTINGS_TEMPLATE = "maven_settings.xml.tmpl" +_DEPLOY_SERVER_TEMPLATE = "maven_deploy_server.xml.tmpl" +_DEPLOY_GOAL_PREFIXES = ("deploy:",) +_DEPLOY_GOAL_SUFFIXES = (":deploy", ":deploy-file") + +# Maven's parser accepts long options with a single dash, so these are not the +# attached form of -s (``-smysettings.xml``) despite starting with it. +_NON_SETTINGS_S_OPTIONS = ("-show-version", "-strict-checksums") + +# release:perform runs a deploy internally; the rest of the release lifecycle +# (prepare, clean, rollback, branch) publishes nothing, so it must not be +# handed the stable server id. +_DEPLOY_RELEASE_GOALS = ("release:perform",) + + +def _cloudsmith_url(host: str, *parts: str) -> str: + """Join the non-empty *parts* into a trailing-slash URL under *host*.""" + path = "/".join(part for part in parts if part) + return f"https://{host}/{path}/" if path else f"https://{host}/" + + +def download_url( + owner: str, repo: str, cdn_host: str, scope: DomainScope = DomainScope.ORGANIZATION +) -> str: + """Return the Maven download (dependency-resolution) repository URL.""" + return _cloudsmith_url( + cdn_host, "basic", repo_path_segment(owner, repo, cdn_host, scope), "maven" + ) + + +def upload_url( + owner: str, + repo: str, + upload_host: str, + scope: DomainScope = DomainScope.ORGANIZATION, +) -> str: + """Return the native Maven upload (distributionManagement) URL.""" + return _cloudsmith_url( + upload_host, repo_path_segment(owner, repo, upload_host, scope) + ) + + +def _supplies_settings(args: list[str]) -> bool: + """True when *args* already points Maven at a settings file. + + Maven's parser accepts the short option attached to its value + (``-smysettings.xml``) as well as separated, so an exact-token match alone + would miss it and let our injected ``-s`` shadow the user's file. That same + parser accepts long options with a single dash, so ``-show-version`` and + ``-strict-checksums`` start with ``-s`` without naming a settings file; + reading one as a settings file would run the whole build unauthenticated. + """ + return any( + arg in _USER_SETTINGS_ARGS + or arg.startswith(("--settings=", "-settings=")) + or ( + arg.startswith("-s") and len(arg) > 2 and arg not in _NON_SETTINGS_S_OPTIONS + ) + for arg in args + ) + + +def _is_deploy_invocation(args: list[str]) -> bool: + """True when *args* makes Maven publish, so the stable server is needed. + + A goal can be given by its fully-qualified plugin coordinates (e.g. + ``org.apache.maven.plugins:maven-deploy-plugin:3.1.1:deploy-file``) - the + same goal spelled out in full rather than via the ``deploy:`` shorthand - + so the coordinate suffixes count too. ``release:perform`` runs a deploy + internally; the rest of the release lifecycle does not, and granting it the + stable id would hand the token to any ``pom.xml`` declaring a repository + under that id on a build that publishes nothing. An arg that merely + contains the word, like a ``deploy-helper:run`` goal or a + ``-Dmaven.deploy.skip=true`` property, must not count either. + """ + return any( + arg == "deploy" + or arg in _DEPLOY_RELEASE_GOALS + or arg.startswith(_DEPLOY_GOAL_PREFIXES) + or arg.endswith(_DEPLOY_GOAL_SUFFIXES) + for arg in args + ) + + +def build_settings_xml( + owner: str, + repo: str, + token: str, + cdn_host: str, + server_id: str, + download_id: str, + scope: DomainScope = DomainScope.ORGANIZATION, + include_deploy_server: bool = False, +) -> str: + """Return a Maven ``settings.xml`` body for the given repo + credentials. + + The download profile/repository/server ids we author ourselves are keyed by + *download_id*, minted unguessably once per install (see + :func:`config.new_download_id`), so a ``pom.xml`` cannot pre-declare a + repository under a guessable id and receive the token on an ordinary build. + It is stable across runs because Maven's local-repository origin tracking + records which repository id an artifact came from; a fresh id per run would + invalidate every cached artifact and break offline builds. The stable + *server_id* - the id the user's ``distributionManagement`` names - is + included only when *include_deploy_server* is set, since only a deploy + needs it. + + Known residual exposure: *server_id* defaults to the literal ``cloudsmith`` + (``config.DEFAULT_REGISTRY_ID``), so on a deploy a ``pom.xml`` declaring an + ordinary repository under that id still receives the token during + dependency resolution. Minting the id as ``cloudsmith-`` at install + time would close it - the value is stored in ``package-managers.ini`` and + printed in the pom snippet the user copies, so it need not be guessable to + anyone. Deferred deliberately: it changes a default that users paste into + ``pom.xml``, so it wants its own change rather than riding along here. + """ + deploy_server = ( + render( + _DEPLOY_SERVER_TEMPLATE, + server_id=escape(server_id), + token=escape(token), + ) + if include_deploy_server + else "" + ) + return render( + _SETTINGS_TEMPLATE, + download_id=escape(download_id), + token=escape(token), + url=escape(download_url(owner, repo, cdn_host, scope)), + deploy_server=deploy_server, + ) + + +class MavenPlugin: + """Provisions an ephemeral ``settings.xml`` and runs ``mvn`` with ``-s``.""" + + name = "maven" + binary_name = "mvn" + # `mvnw` is the Maven Wrapper, the dominant invocation in modern projects. + # Only `mvn` is shimmed, but `cloudsmith exec -- ./mvnw` is explicit intent + # and must not run unauthenticated. + binary_names = ("mvn", "mvnw") + + def skip_auth_args(self) -> list[str]: + """Args for which Maven needs no Cloudsmith credentials.""" + return list(_SKIP_AUTH_ARGS) + + def skip_provision_reason(self, args: list[str]) -> str | None: + """A user-supplied settings file wins over injection. + + Prepending our own ``-s`` as well would silently shadow the user's + file — Maven takes the first occurrence. + """ + if _supplies_settings(args): + return ( + "the command supplies its own -s/--settings file; running mvn " + "without Cloudsmith credential injection" + ) + return None + + def provision( + self, entry: PluginEntry, token: str, args: list[str] + ) -> ProvisionResult: + """Write a 0600 ``settings.xml`` and return ``-s `` to prepend. + + Atomic: if writing fails the temp dir is removed before re-raising, so + a partial provisioning never leaks a directory. + """ + temp_dir = tempfile.mkdtemp(prefix="cloudsmith-maven-") + written = False + try: + settings_path = os.path.join(temp_dir, "settings.xml") + content = build_settings_xml( + owner=entry.owner, + repo=entry.repo, + token=token, + cdn_host=entry.cdn_host, + server_id=entry.registry_id, + download_id=entry.download_id, + scope=domain_scope(entry.cdn_scope), + include_deploy_server=_is_deploy_invocation(args), + ) + fd = os.open(settings_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + written = True + return ProvisionResult( + prepend_args=["-s", settings_path], + temp_dirs=[temp_dir], + ) + finally: + if not written: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/cloudsmith_cli/credential_helpers/shellplugin/plugin.py b/cloudsmith_cli/credential_helpers/shellplugin/plugin.py new file mode 100644 index 00000000..f050d333 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/plugin.py @@ -0,0 +1,80 @@ +# Copyright 2026 Cloudsmith Ltd +"""Plugin protocol and registry for shell-plugin credential helpers. + +A *plugin* knows how to provision ephemeral credentials for one package +manager that lacks a native credential helper (e.g. Maven). The runner looks +a plugin up by the command being run (its binary name) and asks it to +provision per-invocation config. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from .config import PluginEntry + + +@dataclass +class ProvisionResult: + """The per-invocation provisioning a plugin produces for one command. + + ``env`` are extra environment variables to set on the child process, + ``prepend_args`` are inserted before the user's arguments, and + ``temp_dirs`` are removed after the child exits. + """ + + env: dict[str, str] = field(default_factory=dict) + prepend_args: list[str] = field(default_factory=list) + temp_dirs: list[str] = field(default_factory=list) + + +class Plugin(Protocol): + """A package-manager shell plugin.""" + + name: str + binary_name: str + #: Every command name this plugin authenticates, including wrappers such as + #: Maven's ``mvnw``. ``binary_name`` is the one the shim is written under. + binary_names: tuple[str, ...] + + def skip_auth_args(self) -> list[str]: + """Args that mean 'no credentials needed' (e.g. ``--version``).""" + + def skip_provision_reason(self, args: list[str]) -> str | None: + """Reason to run unwrapped, or None to provision. + + E.g. the user supplied their own settings file, which injected config + would silently shadow. + """ + + def provision( + self, entry: PluginEntry, token: str, args: list[str] + ) -> ProvisionResult: + """Write ephemeral credentials and return how to run the binary.""" + + +def _registry() -> dict[str, Plugin]: + """Build the plugin registry (imported lazily to avoid import cycles).""" + from .maven import MavenPlugin + + return {"maven": MavenPlugin()} + + +def get(name: str) -> Plugin | None: + """Return the registered plugin for *name*, or ``None``.""" + return _registry().get(name) + + +def get_by_binary(binary_name: str) -> Plugin | None: + """Return the plugin that authenticates *binary_name*, or ``None``.""" + for plugin in _registry().values(): + if binary_name in plugin.binary_names: + return plugin + return None + + +def names() -> list[str]: + """Return the sorted names of registered plugins.""" + return sorted(_registry()) diff --git a/cloudsmith_cli/credential_helpers/shellplugin/runner.py b/cloudsmith_cli/credential_helpers/shellplugin/runner.py new file mode 100644 index 00000000..7c8d7188 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/runner.py @@ -0,0 +1,237 @@ +# Copyright 2026 Cloudsmith Ltd +"""Run a command with Cloudsmith credentials provisioned for it. + +``cloudsmith exec -- `` (or a shim that forwards to it) lands here. +The plugin is inferred from the command's binary name; when one matches we +provision ephemeral credentials, run the *real* binary (resolved with the +shims dir excluded to avoid recursion), and clean up. Commands with no +matching plugin run unchanged. +""" + +from __future__ import annotations + +import logging +import os +import shutil +import subprocess +import sys +from dataclasses import replace + +from ..default_domains import DomainScope, domain_scope +from . import config, plugin + +logger = logging.getLogger(__name__) + + +def _canonical(path: str) -> str: + """Normalise *path* for comparison, resolving symlinks.""" + return os.path.normcase(os.path.realpath(path)) + + +def resolve_real_binary(binary_name: str, exclude_dirs: list[str]) -> str | None: + """Return the first ``$PATH`` match for *binary_name* outside *exclude_dirs*. + + Excluding the shims dir prevents a shim from re-invoking itself forever. + Comparison is by real path: a PATH entry that is a symlink to the shims + dir, or a symlink pointing at a shim binary, is excluded just the same. + """ + excluded = {_canonical(d) for d in exclude_dirs} + for entry in os.environ.get("PATH", "").split(os.pathsep): + if not entry or _canonical(entry) in excluded: + continue + candidate = shutil.which(binary_name, path=entry) + if not candidate: + continue + if os.path.dirname(_canonical(candidate)) in excluded: + continue + return candidate + return None + + +def _run_process(path: str, args: list[str], env: dict[str, str]) -> int: + """Run *path* with *args* and *env*, returning its exit code. + + A child killed by a signal comes back as a negative returncode, which is + not an exit status: passing it to ``sys.exit`` would truncate it to the low + 8 bits (SIGKILL becoming 247 rather than 137). It is translated to the + shell's ``128 + signal`` convention so callers can tell an OOM kill from an + ordinary build failure. + """ + completed = subprocess.run([path, *args], env=env, check=False) + if completed.returncode < 0: + return 128 - completed.returncode + return completed.returncode + + +def _drop_repository_scoped_hosts(entry: config.PluginEntry) -> config.PluginEntry: + """Reset hosts bound to one repository when the repository changes. + + A repository-scoped custom domain serves exactly the repository it was + discovered for, so carrying it into a different one would resolve the run + against the wrong repository - the same reasoning as the organisation + override above. + """ + replacements = {} + if domain_scope(entry.cdn_scope) is DomainScope.REPOSITORY: + replacements["cdn_host"] = config.default_cdn_host() + replacements["cdn_scope"] = DomainScope.ORGANIZATION.value + if domain_scope(entry.upload_scope) is DomainScope.REPOSITORY: + replacements["upload_host"] = config.default_upload_host() + replacements["upload_scope"] = DomainScope.ORGANIZATION.value + return replace(entry, **replacements) if replacements else entry + + +def _resolve_entry( + format_name: str, + owner: str | None, + repo: str | None, + default_owner: str | None, + default_repo: str | None, +) -> config.PluginEntry: + """Load the stored entry for *format_name*, else build one from inputs. + + Explicit owner/repo (``exec --org/--repo`` flags) override the stored + binding for this invocation. The defaults (environment-sourced values) + apply only when nothing is stored: OIDC users keep ``CLOUDSMITH_ORG`` + exported permanently, and letting it override half of a stored binding + would point wrapped runs at a repository that does not exist. + + A different org discards the rest of the stored binding: its repo slug and + resolved hosts belong to the previous org, so carrying them over would + resolve the run against a repository the user did not ask for. + """ + entry = config.get_plugin(format_name) + if entry is None: + return config.PluginEntry.from_dict( + { + "owner": owner or default_owner or "", + "repo": repo or default_repo or "", + } + ) + if owner and owner != entry.owner: + return config.PluginEntry.from_dict( + { + "owner": owner, + "repo": repo or "", + "registry_id": entry.registry_id, + } + ) + if repo and repo != entry.repo: + entry = _drop_repository_scoped_hosts(entry) + overrides = { + key: value for key, value in (("owner", owner), ("repo", repo)) if value + } + if overrides: + entry = replace(entry, **overrides) + return entry + + +def _needs_auth(args: list[str], skip_auth_args: list[str]) -> bool: + """Return False when any arg signals an auth-free command (help/version). + + Matching anywhere in the command is intentional: these flags (e.g. Maven's + ``--version``/``help``) short-circuit the tool regardless of position, so + there is nothing to authenticate. + """ + skip = set(skip_auth_args) + return not any(arg in skip for arg in args) + + +def run( + command: list[str], + credential=None, + owner: str | None = None, + repo: str | None = None, + default_owner: str | None = None, + default_repo: str | None = None, +) -> int: + """Run *command* with credentials provisioned for its package manager. + + Returns the child process exit code, or non-zero on a setup error. + """ + if not command: + print("cloudsmith: exec requires a command to run", file=sys.stderr) + return 2 + + binary_name, args = command[0], command[1:] + real_binary = resolve_real_binary( + binary_name, exclude_dirs=[str(config.shims_dir())] + ) + if real_binary is None: + print(f"cloudsmith: command not found: {binary_name}", file=sys.stderr) + return 127 + + # The plugin is named after the binary, so a path-qualified command + # (`./mvnw`, `/usr/local/bin/mvn`) has to be matched on its file name - + # otherwise it silently runs with no credentials at all. + impl = plugin.get_by_binary(os.path.basename(binary_name)) + if impl is None or not _needs_auth(args, impl.skip_auth_args()): + return _run_process(real_binary, args, dict(os.environ)) + + skip_reason = impl.skip_provision_reason(args) + if skip_reason: + print(f"cloudsmith: warning: {skip_reason}", file=sys.stderr) + return _run_process(real_binary, args, dict(os.environ)) + + entry = _usable_entry(impl, owner, repo, default_owner, default_repo) + if entry is None: + return 2 + + return _provision_and_run(impl, entry, real_binary, args, credential) + + +def _usable_entry( + impl, + owner: str | None, + repo: str | None, + default_owner: str | None, + default_repo: str | None, +) -> config.PluginEntry | None: + """Return the binding to run under, or None after reporting why not. + + The shim wraps every invocation of the binary, so a misconfiguration has to + be reported as a message rather than raised through the tool the user was + actually trying to run. + """ + try: + entry = _resolve_entry(impl.name, owner, repo, default_owner, default_repo) + except ValueError as exc: + # A trusted [domains] table that declares no host for this format has + # no default to fall back on. + print(f"cloudsmith: {exc}", file=sys.stderr) + return None + + if not (entry.owner and entry.repo): + print( + f"cloudsmith: {impl.name} is not configured; run " + f"`cloudsmith credential-helper install {impl.name} " + "--org --repo ` or pass --org/--repo to " + "`cloudsmith exec`", + file=sys.stderr, + ) + return None + return entry + + +def _provision_and_run(impl, entry, real_binary, args, credential) -> int: + """Provision credentials via *impl*, run the binary, and clean up.""" + token = credential.api_key if credential else None + if not token: + print( + "cloudsmith: warning: no credential resolved; private repositories " + "will fail to authenticate — set CLOUDSMITH_API_KEY or configure OIDC", + file=sys.stderr, + ) + try: + result = impl.provision(entry, token or "", args) + except OSError as exc: + # Boundary: a failed provisioning must not crash the wrapped tool with + # a traceback. provision() cleans up its own temp dir before raising. + print(f"cloudsmith: failed to provision credentials: {exc}", file=sys.stderr) + return 1 + try: + env = {**os.environ, **result.env} + return _run_process(real_binary, [*result.prepend_args, *args], env) + finally: + for temp_dir in result.temp_dirs: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/cloudsmith_cli/credential_helpers/shellplugin/shellinit.py b/cloudsmith_cli/credential_helpers/shellplugin/shellinit.py new file mode 100644 index 00000000..c2c5402b --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/shellinit.py @@ -0,0 +1,49 @@ +# Copyright 2026 Cloudsmith Ltd +"""Shell initialisation for the package-manager shims. + +Prints a snippet for ``eval "$(cloudsmith credential-helper shell-init)"`` that +prepends the Cloudsmith shims directory to ``$PATH`` so the shims shadow the +real binaries. A single PATH prepend covers every enabled format (the shims +dir holds them all). +""" + +from __future__ import annotations + +import os + +from .config import shims_dir + + +def _posix_init(path: str) -> str: + return ( + "# Put Cloudsmith package-manager shims ahead of the real binaries\n" + f'export PATH="{path}:$PATH"\n' + ) + + +def _fish_init(path: str) -> str: + return ( + "# Put Cloudsmith package-manager shims ahead of the real binaries\n" + f'fish_add_path "{path}"\n' + ) + + +_BUILDERS = {"bash": _posix_init, "zsh": _posix_init, "fish": _fish_init} + + +def generate_init(shell: str) -> str: + """Return the shell-init snippet for *shell* (bash/zsh/fish).""" + try: + builder = _BUILDERS[shell] + except KeyError: + supported = ", ".join(sorted(_BUILDERS)) + raise ValueError( + f"unsupported shell {shell!r}; choose one of: {supported}" + ) from None + return builder(str(shims_dir())) + + +def detect_shell() -> str: + """Best-effort shell detection from ``$SHELL``, defaulting to bash.""" + name = os.path.basename(os.environ.get("SHELL", "")) + return name if name in _BUILDERS else "bash" diff --git a/cloudsmith_cli/credential_helpers/shellplugin/shims.py b/cloudsmith_cli/credential_helpers/shellplugin/shims.py new file mode 100644 index 00000000..cbe602c5 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/shims.py @@ -0,0 +1,44 @@ +# Copyright 2026 Cloudsmith Ltd +"""Shim writer for shell-plugin package managers. + +A shim is a tiny launcher (reusing the credential-helper launcher body) named +after the real binary (e.g. ``mvn``) that re-execs ``cloudsmith exec -- +"$@"``. Placed first on PATH (via ``credential-helper shell-init``), it +shadows the real binary so every invocation is wrapped with Cloudsmith +credentials; ``exec`` infers the right plugin from the binary name. +""" + +from __future__ import annotations + +from pathlib import Path + +from ..launchers import ( + cloudsmith_command, + launcher_filename, + remove_launcher, + write_launcher, +) + + +def shim_target_cmd(binary_name: str) -> str: + """Return the command a shim for *binary_name* forwards to.""" + return cloudsmith_command("exec", "--", binary_name) + + +def shim_path(shims_dir: Path, binary_name: str) -> Path: + """Return the path :func:`write_shim` uses on this platform. + + The launcher carries a ``.cmd`` suffix on Windows, so callers that probe + for an installed shim must not assume the bare binary name. + """ + return Path(shims_dir) / launcher_filename(binary_name) + + +def write_shim(shims_dir: Path, binary_name: str) -> Path: + """Write a shim named *binary_name* that wraps it via ``cloudsmith exec``.""" + return write_launcher(shims_dir, binary_name, shim_target_cmd(binary_name)) + + +def remove_shim(shims_dir: Path, binary_name: str) -> bool: + """Remove a shim previously created by :func:`write_shim`.""" + return remove_launcher(shims_dir, binary_name) diff --git a/cloudsmith_cli/templates/maven_deploy_server.xml.tmpl b/cloudsmith_cli/templates/maven_deploy_server.xml.tmpl new file mode 100644 index 00000000..4b8a2308 --- /dev/null +++ b/cloudsmith_cli/templates/maven_deploy_server.xml.tmpl @@ -0,0 +1,5 @@ + + + token + + diff --git a/cloudsmith_cli/templates/maven_distribution_management.xml.tmpl b/cloudsmith_cli/templates/maven_distribution_management.xml.tmpl new file mode 100644 index 00000000..bd9813e4 --- /dev/null +++ b/cloudsmith_cli/templates/maven_distribution_management.xml.tmpl @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/cloudsmith_cli/templates/maven_settings.xml.tmpl b/cloudsmith_cli/templates/maven_settings.xml.tmpl new file mode 100644 index 00000000..93e2f355 --- /dev/null +++ b/cloudsmith_cli/templates/maven_settings.xml.tmpl @@ -0,0 +1,30 @@ + + + + + token + + + + + + + + + + + + + + + + + + + + + + + + +