diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad58295..77d6e8d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `cloudsmith credential-helper domains` emits a versioned JSON document — `{"version": 1, "domains": [{"host": ..., "format": ..., "enabled": ..., "validated": ..., "type": ..., "domain_type": ..., "org": ..., "repository": ...}]}` — listing the hosts Cloudsmith can authenticate: the built-in service domains plus any custom domains configured for an organisation, along with the package format each one serves (`format` is `null` for hosts that serve no single format, like the download CDN). Each entry's `domain_type` says what the host is for — `download` for the CDN, `upload` for the generic upload endpoint, or `native_api` for a host that speaks a single package format's own protocol — so a consumer can pick the right host without pattern-matching on hostnames. `org` and `repository` say which organisation a custom domain belongs to and, when it is scoped to one, which repository — built-in entries carry `null` for both. Disabled and unvalidated custom domains are included too, with their state intact, so a misconfigured domain is visible rather than silently missing. A failed custom-domain lookup (typo'd organisation, missing permission, unreachable API) exits non-zero with a message on stderr instead of silently listing built-in hosts only. The command takes no arguments: the organisation is resolved from `CLOUDSMITH_ORG` or `oidc_org` in `config.ini`. With an organisation configured, its custom domains are looked up (and a failed lookup still exits non-zero); with none configured, the command instead lists whatever custom domains an earlier run already cached, at no API cost, rather than querying every organisation the caller belongs to. Custom-domain lookups are cached on disk for seven days, since custom domains change rarely; pass `--refresh` to bypass the cache and fetch fresh data, for example right after adding or validating a domain. Like `credential-helper generic`, this command is for programmatic consumers only and has no human-readable output mode. - The built-in domain list can be replaced via a `[domains]` section in `config.ini`, mapping hostname to package format, for dedicated and on-premise deployments. For safety this is honoured only from trusted configuration — your user-level config directory, `~/.cloudsmith`, or an explicit `--config-file`. A `[domains]` section in a directory-relative `config.ini` is ignored with a warning, because that file can be committed to a repository and this list decides which hosts may receive a credential. +- `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 a random id generated fresh for that invocation 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. The stable server id you put in `distributionManagement` is supplied only when the invocation is actually a deploy (`deploy`, `deploy:*`, `release:*` since `release:perform` runs a deploy internally, or a goal given by its fully-qualified plugin coordinates like `org.apache.maven.plugins:maven-deploy-plugin:3.1.1:deploy`), since that is the only case that needs 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.20.2] - 2026-07-31 diff --git a/cloudsmith_cli/cli/commands/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index 634add8f..42ed166a 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -10,6 +10,7 @@ docs, 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 6ec81b00..4104715b 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -13,6 +13,7 @@ from .domains import domains_cmd from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd +from .shell import shell_init @click.group() @@ -20,22 +21,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`` and ``domains`` emit machine-readable JSON for tools that + shell out to the CLI instead of importing it. 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(domains_cmd, name="domains") 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 2dd4b395..a1e21489 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -8,12 +8,13 @@ from __future__ import annotations -import os import sys 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, @@ -28,6 +29,7 @@ _INSTALLERS: dict[str, type] = { "docker": DockerInstaller, + "maven": MavenInstaller, } @@ -74,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", @@ -86,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", @@ -97,7 +100,21 @@ def _get_installer(name: str): @click.option( "--org", default=None, - help="Cloudsmith organisation slug for custom-domain discovery.", + envvar="CLOUDSMITH_ORG", + help="Cloudsmith organisation slug.", +) +@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 @@ -114,10 +131,14 @@ def install_cmd( no_discover: bool, refresh: bool, org: str | None, + 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: @@ -125,6 +146,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 @@ -138,10 +163,34 @@ def install_cmd( $ cloudsmith credential-helper install docker --no-discover """ installer = _get_installer(helper) - org = org or os.environ.get("CLOUDSMITH_ORG", "").strip() or None + + # click passes an env var through verbatim, so a padded CLOUDSMITH_ORG / + # CLOUDSMITH_REPO would otherwise be written into the binding as a slug. + org = org.strip() or None if org else None + repo = repo.strip() or None if repo else None + + per_repo = getattr(installer, "requires_repo", False) + if per_repo and not (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, @@ -149,6 +198,7 @@ def install_cmd( org=org, credential=opts.credential, api_host=opts.api_host, + **extra, ) except OSError as exc: raise click.ClickException( @@ -210,8 +260,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..f7a538bc --- /dev/null +++ b/cloudsmith_cli/cli/commands/exec_.py @@ -0,0 +1,58 @@ +# 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 ..decorators import common_api_auth_options, resolve_credentials +from .main import main + + +@main.command(name="exec", context_settings={"ignore_unknown_options": True}) +@click.option( + "--org", + default=None, + envvar="CLOUDSMITH_ORG", + help="Cloudsmith organisation slug. As a flag this overrides the stored " + "binding; from the environment it applies only when nothing is stored.", +) +@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, org, 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 + """ + + def flag_value(name, value): + source = ctx.get_parameter_source(name) + return value if source == ParameterSource.COMMANDLINE else None + + exit_code = runner.run( + list(command), + credential=opts.credential, + owner=flag_value("org", org), + repo=flag_value("repo", repo), + default_owner=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..03d7a55c --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_installer.py @@ -0,0 +1,621 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for the Maven installer and the credential-helper CLI wiring.""" + +from __future__ import annotations + +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.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.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 _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( + CustomDomain( + host=cdn_host, + backend_kind=None, + enabled=True, + validated=True, + org="acme", + ) + ) + if upload_host: + domains.append( + CustomDomain( + host=upload_host, + backend_kind=int(BackendKind.MAVEN), + enabled=True, + validated=True, + org="acme", + ) + ) + + captured = {} + + def _fake(org, **kwargs): + captured.update(kwargs) + if raises: + raise RuntimeError("network down") + return domains + + monkeypatch.setattr(_INSTALLER_GET_CUSTOM_DOMAINS, _fake) + return captured + + +def _repo_domain(host, backend_kind, repo): + """A repository-scoped custom domain record.""" + return CustomDomain( + host=host, + backend_kind=backend_kind, + enabled=True, + validated=True, + org="acme", + repository=repo, + repository_only=True, + ) + + +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: [ + CustomDomain( + host="dl.acme.com", + backend_kind=None, + enabled=True, + validated=True, + org="acme", + ), + _repo_domain("dl-prod.acme.com", None, "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: [_repo_domain("dl-dev.acme.com", None, "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 = [ + CustomDomain( + host="zzz.acme.com", + backend_kind=None, + enabled=True, + validated=True, + org="acme", + ), + CustomDomain( + host="aaa.acme.com", + backend_kind=None, + enabled=True, + validated=True, + org="acme", + ), + ] + + 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]\ncdn.internal.example.com =\nmvn.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(installer_module.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(installer_module.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(installer_module.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(): + 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 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..34fc44e2 --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_plugin.py @@ -0,0 +1,546 @@ +# 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", + ) + + 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", + ) + # 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", + 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 =\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_differs_between_calls(): + """Two successive provision() calls mint different random download ids.""" + 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 + + +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 "a 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"] diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index a455b5d0..8c4774f2 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -9,6 +9,7 @@ import os from .custom_domains import get_custom_domains, get_format_domains +from .default_domains import DomainScope, load_default_domains logger = logging.getLogger(__name__) @@ -48,6 +49,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): """ Check if a URL points to a Cloudsmith service. @@ -71,11 +119,7 @@ def is_cloudsmith_domain(url, credential=None, api_host=None, backend_kind=None) 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/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index acf978ef..68920ecf 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -29,6 +29,11 @@ def _launcher_filename(name: str, *, windows: bool) -> str: 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 +106,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..c26a5b03 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -0,0 +1,373 @@ +# 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 +import sys +from dataclasses import dataclass, replace +from xml.sax.saxutils import escape + +from ...core.credentials.models import CredentialResult +from ...templates import render +from ..backends import BackendKind +from ..custom_domains import CustomDomain, get_custom_domains +from ..default_domains import DomainScope +from ..launchers import 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, repo: str | None +) -> bool: + """True when *domain* can serve *repo* for *backend_kind*. + + A domain bound to a different repository serves only that repository, so + it cannot back this binding at all. + """ + if domain.backend_kind != backend_kind: + return False + if not (domain.enabled and domain.validated): + return False + if domain.scope is DomainScope.REPOSITORY: + return domain.repository == repo + return True + + +def _select_domain( + domains: list[CustomDomain], backend_kind: int | None, repo: str | None +) -> CustomDomain | None: + """Return the best domain for *backend_kind*, most specific first. + + Candidates are sorted by host so the choice is deterministic: the + discovery API gives no ordering guarantee, and picking raw index 0 would + let two installs of the same repo bind different hosts depending on + server-returned order alone. + """ + candidates = sorted( + (d for d in domains if _is_eligible(d, backend_kind, repo)), + key=lambda d: d.host, + ) + for candidate in candidates: + if candidate.scope is DomainScope.REPOSITORY: + return candidate + 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 + ``shellplugin.shims.shim_target_cmd``), so it does not depend on + `cloudsmith` being on PATH. + """ + if getattr(sys, "frozen", False): + 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 + []).""" + if not (org and credential): + return [] + try: + return get_custom_domains( + org, + credential=credential, + api_host=api_host, + refresh=refresh, + ) + except Exception as exc: # pylint: disable=broad-except + # Discovery boundary: never let a lookup failure abort the install. + 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, repo) + upload = _select_domain(discovered, BackendKind.MAVEN, 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, + hosts=hosts, + actions=actions, + ) + + def _apply_domain_overrides( + self, + *, + domains_override: tuple[str, ...], + discovered: list[CustomDomain], + 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 backend + kind discovery reports for it: a native Maven domain replaces the + upload host, anything else the download host. 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) + 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) + + if dry_run: + actions.append(f"would write shim {shim}") + actions.append( + f"would configure maven for {owner}/{repo} " + f"(download {hosts.cdn_host}, upload {hosts.upload_host})" + ) + actions.append(_USER_SETTINGS_NOTE) + actions.append( + _deploy_snippet( + owner or "", + repo or "", + hosts.upload_host, + registry_id, + hosts.upload_scope, + ) + ) + return actions + + write_shim(shims_dir, self.BINARY_NAME) + actions.append(f"wrote shim {shim}") + + 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 maven for {owner}/{repo} " + f"(download {hosts.cdn_host}, upload {hosts.upload_host})" + ) + + 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..3f8181ba --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/config.py @@ -0,0 +1,137 @@ +# 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 +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:" + + +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 + + @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, + ) + + 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: + parser = configparser.ConfigParser(interpolation=None) + path = config_path() + if path.exists(): + parser.read(path, encoding="utf-8") + 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..b83d0ab9 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/maven.py @@ -0,0 +1,200 @@ +# 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 a fresh +random id every call — 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. 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 secrets +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_TEMPLATE = "maven_settings.xml.tmpl" +_DEPLOY_SERVER_TEMPLATE = "maven_deploy_server.xml.tmpl" +_DEPLOY_GOAL_PREFIXES = ("deploy:", "release:") + + +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. + """ + return any( + arg in _USER_SETTINGS_ARGS + or arg.startswith("--settings=") + or (arg.startswith("-s") and len(arg) > 2) + for arg in args + ) + + +def _is_deploy_invocation(args: list[str]) -> bool: + """True when *args* makes Maven publish, so the stable server is needed. + + ``release:perform`` runs a deploy internally, so it counts too. A goal can + also be given by its fully-qualified plugin coordinates (e.g. + ``org.apache.maven.plugins:maven-deploy-plugin:3.1.1:deploy``) - the same + goal spelled out in full rather than via the ``deploy:`` shorthand - so an + arg ending in ``:deploy`` counts too. An arg that merely contains the + word, like a ``deploy-helper:run`` goal or a ``-Dmaven.deploy.skip=true`` + property, must not: a plain ``install``/``compile`` (or deploy explicitly + disabled) never needs the id a ``pom.xml`` names in + ``distributionManagement``. + """ + return any( + arg == "deploy" + or arg.startswith(_DEPLOY_GOAL_PREFIXES) + or arg.endswith(":deploy") + for arg in args + ) + + +def build_settings_xml( + owner: str, + repo: str, + token: str, + cdn_host: str, + server_id: str, + scope=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 get a + fresh unguessable id every call, so a ``pom.xml`` cannot pre-declare a + repository under a guessable id and receive the token on an ordinary + build. 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. + """ + download_id = f"cloudsmith-{secrets.token_hex(8)}" + 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" + + 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, + 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..65c52c7c --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/plugin.py @@ -0,0 +1,77 @@ +# 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 + + 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 whose ``binary_name`` matches, or ``None``.""" + for plugin in _registry().values(): + if plugin.binary_name == binary_name: + 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..cbc049d4 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/runner.py @@ -0,0 +1,199 @@ +# 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.""" + completed = subprocess.run([path, *args], env=env, check=False) + 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 + + impl = plugin.get_by_binary(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 = _resolve_entry(impl.name, owner, repo, default_owner, default_repo) + 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 2 + + return _provision_and_run(impl, entry, real_binary, args, credential) + + +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..960606c9 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/shellplugin/shims.py @@ -0,0 +1,48 @@ +# 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 + +import sys +from pathlib import Path + +from ..launchers import launcher_filename, remove_launcher, write_launcher + + +def shim_target_cmd(binary_name: str) -> str: + """Return the command a shim for *binary_name* forwards to. + + A frozen standalone binary (PyInstaller) is not guaranteed to be on PATH + under the name ``cloudsmith``, so point the shim at the absolute executable + instead — mirroring ``DockerInstaller._resolve_target_cmd``. The path is + quoted so a directory containing spaces still execs correctly. + """ + if getattr(sys, "frozen", False): + return f'"{sys.executable}" exec -- {binary_name}' + return f"cloudsmith 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 + + + + + + + + + + + + + + + + + + + + + + + + +