diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e1d0ce..931f6ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- `cloudsmith credential-helper install maven --org --repo ` sets up transparent Maven authentication. Maven has no credential-helper protocol, so the CLI installs an `mvn` shim that wraps every invocation in `cloudsmith exec`, activated by putting the shims directory first on `PATH`. Wrapped runs resolve dependencies from the bound repository through a mode-0600 `settings.xml` injected via `mvn -s` and deleted when the run ends, never written to `~/.m2` — and `~/.m2/settings.xml` is not consulted, so mirrors and proxies declared there do not apply. Passing your own `-s/--settings` runs Maven unwrapped, with a warning. Publishing is opt-in: `install` prints the `distributionManagement` snippet to add to `pom.xml`. Custom download and upload domains are discovered from the organisation as for the Docker helper. +- `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 in CI without touching `PATH`. The package manager is detected from the command name, and help and version invocations pass straight through unwrapped. +- `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. + +### Security + +- The `` id in the generated Maven `settings.xml` is `cloudsmith` by default, matching the `distributionManagement` snippet `install` prints so a team can share one `pom.xml`. Maven matches a server's credentials to a repository by id alone, with no host check, so a `pom.xml` declaring a repository under that id receives the token — the same exposure as the `~/.m2/settings.xml` a Maven user would otherwise keep. Pass `--server-id` at install time to bind the credential to an id a third-party `pom.xml` cannot guess. + ## [1.23.0] - 2026-08-14 ### Added diff --git a/cloudsmith_cli/cli/commands/__init__.py b/cloudsmith_cli/cli/commands/__init__.py index 0ad6fc2b..63162c10 100644 --- a/cloudsmith_cli/cli/commands/__init__.py +++ b/cloudsmith_cli/cli/commands/__init__.py @@ -11,6 +11,7 @@ domains, download, entitlements, + exec_, help_, list_, login, diff --git a/cloudsmith_cli/cli/commands/credential_helper/__init__.py b/cloudsmith_cli/cli/commands/credential_helper/__init__.py index 91d12bb9..047679f4 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/__init__.py +++ b/cloudsmith_cli/cli/commands/credential_helper/__init__.py @@ -12,6 +12,7 @@ from .docker import docker as docker_cmd from .generic import generic as generic_cmd from .manage import install_cmd, list_cmd, uninstall_cmd +from .shell import shell_init @click.group() @@ -19,21 +20,30 @@ 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``. 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 """ credential_helper.add_command(docker_cmd, name="docker") credential_helper.add_command(generic_cmd, name="generic") +credential_helper.add_command(shell_init, name="shell-init") credential_helper.add_command(install_cmd, name="install") credential_helper.add_command(uninstall_cmd, name="uninstall") credential_helper.add_command(list_cmd, name="list") diff --git a/cloudsmith_cli/cli/commands/credential_helper/manage.py b/cloudsmith_cli/cli/commands/credential_helper/manage.py index 35cdc825..ade674e6 100644 --- a/cloudsmith_cli/cli/commands/credential_helper/manage.py +++ b/cloudsmith_cli/cli/commands/credential_helper/manage.py @@ -13,6 +13,8 @@ import click from ....credential_helpers.docker.installer import DockerInstaller +from ....credential_helpers.maven.config import DEFAULT_SERVER_ID +from ....credential_helpers.maven.installer import MavenInstaller from ... import utils from ...decorators import ( common_api_auth_options, @@ -27,6 +29,7 @@ _INSTALLERS: dict[str, type] = { "docker": DockerInstaller, + "maven": MavenInstaller, } @@ -73,7 +76,7 @@ def _get_installer(name: str): "--domain", "domains", multiple=True, - help="Additional registry hostname to configure (repeatable).", + help="Additional registry hostname to configure (repeatable). Docker only.", ) @click.option( "--dry-run", @@ -85,7 +88,7 @@ def _get_installer(name: str): "--no-discover", is_flag=True, default=False, - help="Disable automatic discovery of custom Docker domains.", + help="Disable automatic discovery of custom domains.", ) @click.option( "--refresh", @@ -93,6 +96,19 @@ def _get_installer(name: str): default=False, help="Bypass the custom-domain cache and fetch fresh data from the API.", ) +@click.option( + "--repo", + default=None, + envvar="CLOUDSMITH_REPO", + help="Maven only: the Cloudsmith repository slug to bind.", +) +@click.option( + "--server-id", + default=DEFAULT_SERVER_ID, + show_default=True, + help="Maven only: the id the credentials are registered under in " + "the generated settings.xml, matching your pom.xml.", +) @common_cli_config_options @common_cli_output_options @common_api_auth_options @@ -107,10 +123,14 @@ def install_cmd( dry_run: bool, no_discover: bool, refresh: bool, + repo: str | None, + server_id: str, ) -> None: """Install a credential helper launcher and configure the package manager. - HELPER is the name of the credential helper to install (e.g. ``docker``). + HELPER is the name of the credential helper to install (``docker`` or + ``maven``). The maven helper binds one repository, so it requires + ``--org`` and ``--repo``. Examples: @@ -118,6 +138,10 @@ def install_cmd( # Install Docker credential helper $ cloudsmith credential-helper install docker + \b + # Install the Maven helper for one repository + $ cloudsmith credential-helper install maven --org my-org --repo my-repo + \b # Install with a custom domain $ cloudsmith credential-helper install docker --domain my.registry.example.com @@ -131,18 +155,37 @@ def install_cmd( $ cloudsmith credential-helper install docker --no-discover """ installer = _get_installer(helper) + + # click passes CLOUDSMITH_REPO through verbatim, and a padded slug would + # be interpolated into a URL and 404. opts.org is already normalised on its + # way through the options object. + repo = (repo or "").strip() or None + + # The Maven helper binds a single repository and keeps its shim in a fixed + # shims directory, so --bin-dir and --domain do not apply to it. + if installer.requires_repo: + if not (opts.org and repo): + raise click.ClickException(f"helper {helper!r} requires --org and --repo.") + for name, value in (("--bin-dir", bin_dir), ("--domain", domains)): + if value: + click.echo(f"Warning: {name} is ignored for {helper!r}.", err=True) + extra: dict = {"repo": repo, "server_id": server_id} + else: + extra = {"bin_dir": bin_dir, "domains": domains} + try: actions = installer.install( - bin_dir=bin_dir, - domains=domains, dry_run=dry_run, discover=not no_discover, refresh=refresh, org=opts.org, credential=opts.credential, api_host=opts.api_host, + **extra, ) - except OSError as exc: + except (OSError, ValueError) as exc: + # ValueError: a trusted [domains] table that declares no host for this + # helper's format, which has no default to fall back on. raise click.ClickException( f"Failed to install {helper!r} credential helper: {exc}" ) @@ -202,8 +245,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 installer.requires_repo 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..6c5885a8 --- /dev/null +++ b/cloudsmith_cli/cli/commands/credential_helper/shell.py @@ -0,0 +1,55 @@ +# 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 os + +import click + +from ....credential_helpers.maven.config import shims_dir + +_COMMENT = "# Put Cloudsmith package-manager shims ahead of the real binaries" + +_POSIX_STATEMENT = 'export PATH="{path}:$PATH"' + +_STATEMENTS = { + "bash": _POSIX_STATEMENT, + "zsh": _POSIX_STATEMENT, + "fish": 'fish_add_path "{path}"', +} + + +def detect_shell(): + """Best-effort shell detection from ``$SHELL``, defaulting to bash.""" + name = os.path.basename(os.environ.get("SHELL", "")) + return name if name in _STATEMENTS else "bash" + + +@click.command(name="shell-init") +@click.option( + "--shell", + "shell_name", + type=click.Choice(sorted(_STATEMENTS)), + 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 + """ + statement = _STATEMENTS[shell_name or detect_shell()] + click.echo(_COMMENT) + click.echo(statement.format(path=shims_dir())) diff --git a/cloudsmith_cli/cli/commands/exec_.py b/cloudsmith_cli/cli/commands/exec_.py new file mode 100644 index 00000000..55b6f05f --- /dev/null +++ b/cloudsmith_cli/cli/commands/exec_.py @@ -0,0 +1,34 @@ +# Copyright 2026 Cloudsmith Ltd +"""CLI/Commands - Run a command with Cloudsmith credentials provisioned.""" + +import sys + +import click + +from ...credential_helpers.maven 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.argument("command", nargs=-1, type=click.UNPROCESSED, required=True) +@common_api_auth_options +@resolve_credentials +@click.pass_context +def exec_(ctx, opts, command): + """Run a package-manager command authenticated against Cloudsmith. + + Wraps the command so it resolves dependencies from your Cloudsmith + repository, with credentials injected for that run and removed afterwards. + This is the machinery the ``mvn`` shim uses, callable directly in CI + without touching ``PATH``. + + Maven runs use a generated ``settings.xml``; your ``~/.m2/settings.xml`` + is not consulted. The repository comes from ``credential-helper install + maven``. The package manager is detected from the command, so just put it + after ``--``: + + \b + $ cloudsmith exec -- mvn clean install + """ + sys.exit(runner.run(list(command), credential=opts.credential)) diff --git a/cloudsmith_cli/cli/tests/commands/conftest.py b/cloudsmith_cli/cli/tests/commands/conftest.py index 0bdeb58d..03276e23 100644 --- a/cloudsmith_cli/cli/tests/commands/conftest.py +++ b/cloudsmith_cli/cli/tests/commands/conftest.py @@ -1,5 +1,34 @@ import pytest +from ....cli import config as cli_config +from ....core.credentials.models import CredentialResult + + +@pytest.fixture() +def cli_config_dir(tmp_path, monkeypatch): + """Point the CLI config dir at a tmp dir, and return it. + + Maven helper state (``package-managers.ini`` and the shims dir) hangs off + it. The trusted config search path is pinned at an empty directory, so + the developer's own ``config.ini`` cannot change the default domains + these tests observe. + """ + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.maven.config.get_default_config_path", + lambda: str(tmp_path), + ) + empty = tmp_path / "empty-trusted-config" + empty.mkdir() + monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"]) + monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", [str(empty)]) + return tmp_path + + +@pytest.fixture() +def credential(): + """A resolved credential, as the provider chain would hand one back.""" + return CredentialResult(api_key="k_abc", source_name="test") + class MockToken: """Mock Token object with the properties needed for testing.""" diff --git a/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven.py b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven.py new file mode 100644 index 00000000..188b5955 --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven.py @@ -0,0 +1,311 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for the Maven settings.xml, stored binding and `cloudsmith exec`.""" + +import os +import stat +from dataclasses import replace +from xml.etree import ElementTree + +import pytest + +from ....credential_helpers.maven import config, runner, settings + +pytestmark = pytest.mark.usefixtures("cli_config_dir") + + +@pytest.fixture() +def binding(): + return config.Binding( + owner="my-org", + repo="my-repo", + download_host="dl.cloudsmith.io", + upload_host="maven.cloudsmith.io", + ) + + +def write_fake_mvn(tmp_path, monkeypatch, script_body): + """Put a fake `mvn` running *script_body* alone on PATH.""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + mvn = bin_dir / "mvn" + mvn.write_text(f"#!/bin/sh\n{script_body}\n") + mvn.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) + + +@pytest.fixture() +def fake_mvn(tmp_path, monkeypatch): + """Put an executable that records its argv on PATH as `mvn`.""" + argv_log = tmp_path / "argv.txt" + write_fake_mvn(tmp_path, monkeypatch, f'printf "%s\\n" "$@" > "{argv_log}"\nexit 0') + return argv_log + + +def _settings_arg(argv_log): + """Return the path Maven was handed with -s, from a recorded argv.""" + argv = argv_log.read_text().splitlines() + return argv[argv.index("-s") + 1] + + +# --------------------------------------------------------------------------- +# settings.xml +# --------------------------------------------------------------------------- + + +def test_settings_xml_carries_the_download_repository_and_token(binding): + """The generated file authenticates dependency resolution on its own.""" + root = ElementTree.fromstring(settings.build_settings_xml(binding, "secret")) + + server = root.find("servers/server") + assert server.findtext("id") == "cloudsmith" + assert server.findtext("username") == "token" + assert server.findtext("password") == "secret" + + repository = root.find("profiles/profile/repositories/repository") + assert repository.findtext("id") == "cloudsmith" + assert ( + repository.findtext("url") + == "https://dl.cloudsmith.io/basic/my-org/my-repo/maven/" + ) + # The profile has to be active, or none of the above applies. + assert root.findtext("activeProfiles/activeProfile") == "cloudsmith" + + +def test_settings_xml_escapes_the_token(binding): + """A token is interpolated into XML, so it must not be able to break out.""" + xml = settings.build_settings_xml(binding, "a&b") + + assert "a&b" not in xml + root = ElementTree.fromstring(xml) + assert root.findtext("servers/server/password") == "a&b" + + +def test_settings_xml_honours_the_server_id(binding): + """--server-id renames every id, so pom.xml and settings.xml still match.""" + xml = settings.build_settings_xml( + replace(binding, server_id="private-id"), "secret" + ) + + assert "private-id" in xml + assert "cloudsmith" not in xml + + +@pytest.mark.parametrize( + "host,expected_download,expected_upload", + [ + ( + "dl.cloudsmith.io", + "https://dl.cloudsmith.io/basic/my-org/my-repo/maven/", + "https://dl.cloudsmith.io/my-org/my-repo/", + ), + # A custom domain is bound to one org, so the org is not in the path. + ( + "maven.example.com", + "https://maven.example.com/basic/my-repo/maven/", + "https://maven.example.com/my-repo/", + ), + ], +) +def test_urls_include_the_org_only_on_default_hosts( + host, expected_download, expected_upload +): + assert settings.download_url("my-org", "my-repo", host) == expected_download + assert settings.upload_url("my-org", "my-repo", host) == expected_upload + + +def test_write_settings_is_not_readable_by_others(tmp_path, binding): + """The file holds a usable token for the lifetime of the run.""" + path = settings.write_settings( + str(tmp_path), settings.build_settings_xml(binding, "secret") + ) + + assert stat.S_IMODE(os.stat(path).st_mode) == 0o600 + + +# --------------------------------------------------------------------------- +# stored binding +# --------------------------------------------------------------------------- + + +def test_binding_round_trips(binding): + config.set_binding(binding) + + assert config.get_binding() == binding + + +def test_remove_binding_reports_whether_there_was_one(binding): + assert config.remove_binding() is False + config.set_binding(binding) + assert config.remove_binding() is True + assert config.get_binding() is None + + +def test_unreadable_config_reads_as_no_binding(cli_config_dir): + """A hand-edited file must not make mvn unusable machine-wide.""" + config.config_path().write_text("not an ini file [[[", encoding="utf-8") + + assert config.get_binding() is None + + +# --------------------------------------------------------------------------- +# runner +# --------------------------------------------------------------------------- + + +def test_run_injects_settings_and_returns_the_exit_code(binding, fake_mvn, credential): + config.set_binding(binding) + + assert runner.run(["mvn", "clean", "install"], credential=credential) == 0 + + argv = fake_mvn.read_text().splitlines() + assert argv[0] == "-s" and argv[2:] == ["clean", "install"] + + +def test_run_deletes_the_settings_file_afterwards(binding, fake_mvn, credential): + """The token must not outlive the run it was provisioned for.""" + config.set_binding(binding) + + runner.run(["mvn", "package"], credential=credential) + + assert not os.path.exists(_settings_arg(fake_mvn)) + + +def test_run_passes_the_token_to_maven(binding, tmp_path, monkeypatch, credential): + """The file mvn is handed is the one holding the resolved credential.""" + copy = tmp_path / "settings-seen.xml" + write_fake_mvn(tmp_path, monkeypatch, f'/bin/cp "$2" "{copy}"\nexit 0') + config.set_binding(binding) + + runner.run(["mvn", "package"], credential=credential) + + assert credential.api_key in copy.read_text() + + +@pytest.mark.parametrize("args", [["--version"], ["-v"], ["help"], ["clean", "--help"]]) +def test_help_and_version_run_unwrapped(binding, fake_mvn, credential, args): + """Nothing to authenticate, so no settings.xml is injected.""" + config.set_binding(binding) + + assert runner.run(["mvn", *args], credential=credential) == 0 + assert fake_mvn.read_text().splitlines() == args + + +@pytest.mark.parametrize( + "args", + [ + ["-s", "mine.xml", "package"], + ["-smine.xml", "package"], + ["--settings", "mine.xml", "package"], + ["--settings=mine.xml", "package"], + ], +) +def test_a_user_supplied_settings_file_wins( + binding, fake_mvn, credential, args, capsys +): + """Prepending our -s as well would silently shadow the user's file.""" + config.set_binding(binding) + + assert runner.run(["mvn", *args], credential=credential) == 0 + + assert fake_mvn.read_text().splitlines() == args + assert "without Cloudsmith credential injection" in capsys.readouterr().err + + +@pytest.mark.parametrize("args", [["-show-version", "package"], ["-strict-checksums"]]) +def test_single_dash_long_options_are_not_mistaken_for_settings( + binding, fake_mvn, credential, args +): + """Maven accepts long options with one dash, so -s* is not always -s.""" + config.set_binding(binding) + + runner.run(["mvn", *args], credential=credential) + + assert fake_mvn.read_text().splitlines()[:2] == ["-s", _settings_arg(fake_mvn)] + + +def test_a_path_qualified_maven_is_still_wrapped( + binding, fake_mvn, credential, tmp_path +): + """`./mvnw` is explicit intent and must not run unauthenticated.""" + config.set_binding(binding) + wrapper = tmp_path / "bin" / "mvnw" + wrapper.write_text((tmp_path / "bin" / "mvn").read_text()) + wrapper.chmod(0o755) + + assert runner.run([str(wrapper), "package"], credential=credential) == 0 + assert fake_mvn.read_text().splitlines()[0] == "-s" + + +def test_a_command_with_no_plugin_runs_unchanged(fake_mvn, tmp_path, credential): + other = tmp_path / "bin" / "gradle" + other.write_text((tmp_path / "bin" / "mvn").read_text()) + other.chmod(0o755) + + assert runner.run(["gradle", "build"], credential=credential) == 0 + assert fake_mvn.read_text().splitlines() == ["build"] + + +def test_run_reports_a_missing_binding(fake_mvn, credential, capsys): + """The shim wraps every mvn, so this has to be a message, not a traceback.""" + assert runner.run(["mvn", "package"], credential=credential) == 2 + assert "credential-helper install maven" in capsys.readouterr().err + + +def test_run_warns_but_proceeds_without_a_credential(binding, fake_mvn, capsys): + """Public repositories still resolve, so this is a warning, not an error.""" + config.set_binding(binding) + + assert runner.run(["mvn", "package"], credential=None) == 0 + assert "no credential resolved" in capsys.readouterr().err + + +def test_run_reports_a_missing_command(credential, capsys): + assert runner.run(["definitely-not-installed"], credential=credential) == 127 + assert "command not found" in capsys.readouterr().err + + +def test_run_requires_a_command(capsys): + assert runner.run([]) == 2 + assert "requires a command" in capsys.readouterr().err + + +def test_a_signalled_child_reports_the_shell_convention( + binding, tmp_path, monkeypatch, credential +): + """A negative returncode is not an exit status; sys.exit would truncate it.""" + write_fake_mvn(tmp_path, monkeypatch, "kill -9 $$") + config.set_binding(binding) + + assert runner.run(["mvn", "package"], credential=credential) == 137 + + +def test_the_shim_directory_is_excluded_when_resolving_the_binary( + tmp_path, monkeypatch +): + """Otherwise a shim re-invokes itself forever.""" + shims = config.shims_dir() + shims.mkdir(parents=True) + (shims / "mvn").write_text("#!/bin/sh\nexit 0\n") + (shims / "mvn").chmod(0o755) + real_dir = tmp_path / "real" + real_dir.mkdir() + (real_dir / "mvn").write_text("#!/bin/sh\nexit 0\n") + (real_dir / "mvn").chmod(0o755) + monkeypatch.setenv("PATH", f"{shims}{os.pathsep}{real_dir}") + + resolved = runner.resolve_real_binary("mvn", str(shims)) + + assert resolved == str(real_dir / "mvn") + + +def test_a_symlink_into_the_shim_directory_is_excluded_too(tmp_path, monkeypatch): + """Comparison is by real path, so an aliased PATH entry does not slip past.""" + shims = config.shims_dir() + shims.mkdir(parents=True) + (shims / "mvn").write_text("#!/bin/sh\nexit 0\n") + (shims / "mvn").chmod(0o755) + alias = tmp_path / "alias" + alias.symlink_to(shims) + monkeypatch.setenv("PATH", str(alias)) + + assert runner.resolve_real_binary("mvn", str(shims)) is None 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..0be92c58 --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_credential_helper_maven_installer.py @@ -0,0 +1,235 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for `cloudsmith credential-helper install/uninstall/list maven`.""" + +import pytest +from click.testing import CliRunner + +from ....core.api.exceptions import ApiException +from ....credential_helpers.backends import BackendKind +from ....credential_helpers.custom_domains import CustomDomain +from ....credential_helpers.default_domains import DomainType +from ....credential_helpers.maven import config +from ....credential_helpers.maven.installer import MavenInstaller +from ...commands.credential_helper.manage import install_cmd +from ...commands.credential_helper.shell import shell_init + +pytestmark = pytest.mark.usefixtures("cli_config_dir") + + +def custom_domain(host, backend_kind, domain_type, **overrides): + """Build a discovered custom-domain record.""" + return CustomDomain( + host=host, + backend_kind=backend_kind, + enabled=overrides.pop("enabled", True), + validated=overrides.pop("validated", True), + org="my-org", + domain_type=domain_type, + **overrides, + ) + + +@pytest.fixture() +def discovered(monkeypatch): + """Control the custom domains discovery returns; append to it to add one.""" + records = [] + + def fake_get_custom_domains(org, **kwargs): + return list(records) + + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.maven.installer.get_custom_domains", + fake_get_custom_domains, + ) + return records + + +def install(credential, **kwargs): + """Install with the defaults the CLI passes, overridden by *kwargs*.""" + return MavenInstaller().install( + org="my-org", repo="my-repo", credential=credential, **kwargs + ) + + +# --------------------------------------------------------------------------- +# install +# --------------------------------------------------------------------------- + + +def test_install_binds_the_repository_and_writes_a_shim(credential, discovered): + actions = install(credential) + + binding = config.get_binding() + assert (binding.owner, binding.repo) == ("my-org", "my-repo") + assert binding.download_host == "dl.cloudsmith.io" + assert binding.upload_host == "maven.cloudsmith.io" + assert (config.shims_dir() / "mvn").exists() + assert any(action.startswith("wrote shim") for action in actions) + + +def test_the_shim_forwards_to_cloudsmith_exec(credential, discovered): + """The shim is what makes a bare `mvn` authenticate at all.""" + install(credential) + + assert "cloudsmith exec -- mvn" in (config.shims_dir() / "mvn").read_text() + + +def test_install_prints_the_deploy_snippet(credential, discovered): + """Publishing is opt-in, so the pom.xml snippet has to be surfaced.""" + actions = install(credential) + + snippet = "\n".join(actions) + assert "" in snippet + assert "https://maven.cloudsmith.io/my-org/my-repo/" in snippet + assert "cloudsmith" in snippet + + +def test_install_warns_that_the_users_own_settings_are_not_consulted( + credential, discovered +): + assert any("~/.m2/settings.xml" in action for action in install(credential)) + + +def test_a_custom_server_id_reaches_both_the_binding_and_the_snippet( + credential, discovered +): + actions = install(credential, server_id="private-id") + + assert config.get_binding().server_id == "private-id" + assert "private-id" in "\n".join(actions) + + +def test_install_binds_discovered_custom_domains(credential, discovered): + """Download and upload are separate endpoints with separate domains.""" + discovered.append(custom_domain("dl.example.com", None, DomainType.DOWNLOAD)) + discovered.append( + custom_domain("mvn.example.com", BackendKind.MAVEN, DomainType.NATIVE_API) + ) + + install(credential) + + binding = config.get_binding() + assert binding.download_host == "dl.example.com" + assert binding.upload_host == "mvn.example.com" + + +def test_an_inactive_custom_domain_is_not_bound(credential, discovered): + """A domain that is disabled or unvalidated serves nothing.""" + discovered.append( + custom_domain("dl.example.com", None, DomainType.DOWNLOAD, validated=False) + ) + + install(credential) + + assert config.get_binding().download_host == "dl.cloudsmith.io" + + +def test_a_repository_scoped_custom_domain_is_not_bound(credential, discovered): + """Its URLs are a different shape, so it defaults rather than guessing.""" + discovered.append( + custom_domain("dl.example.com", None, DomainType.DOWNLOAD, repository="my-repo") + ) + + install(credential) + + assert config.get_binding().download_host == "dl.cloudsmith.io" + + +def test_discovery_failure_warns_rather_than_binding_silently(credential, monkeypatch): + """An unreachable API must not read as "this org has no custom domains".""" + + def raise_api_error(org, **kwargs): + raise ApiException(status=500, detail="boom") + + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.maven.installer.get_custom_domains", + raise_api_error, + ) + + actions = install(credential) + + assert any( + action.startswith("WARNING: custom-domain discovery") for action in actions + ) + assert config.get_binding().download_host == "dl.cloudsmith.io" + + +def test_no_discover_skips_the_api_entirely(credential, monkeypatch): + def fail(org, **kwargs): + raise AssertionError("discovery should not run") + + monkeypatch.setattr( + "cloudsmith_cli.credential_helpers.maven.installer.get_custom_domains", fail + ) + + install(credential, discover=False) + + assert config.get_binding() is not None + + +def test_dry_run_changes_nothing(credential, discovered): + actions = install(credential, dry_run=True) + + assert config.get_binding() is None + assert not (config.shims_dir() / "mvn").exists() + assert any(action.startswith("would write shim") for action in actions) + + +# --------------------------------------------------------------------------- +# uninstall / list +# --------------------------------------------------------------------------- + + +def test_uninstall_removes_the_shim_and_the_binding(credential, discovered): + install(credential) + + actions = MavenInstaller().uninstall() + + assert config.get_binding() is None + assert not (config.shims_dir() / "mvn").exists() + assert any(action.startswith("removed shim") for action in actions) + + +def test_uninstall_is_safe_when_nothing_is_installed(): + actions = MavenInstaller().uninstall() + + assert any("nothing to remove" in action for action in actions) + + +def test_uninstall_dry_run_changes_nothing(credential, discovered): + install(credential) + + MavenInstaller().uninstall(dry_run=True) + + assert config.get_binding() is not None + assert (config.shims_dir() / "mvn").exists() + + +def test_status_reports_the_binding(credential, discovered): + assert MavenInstaller().status() == {"launcher": None, "hosts": []} + + install(credential) + + status = MavenInstaller().status() + assert status["launcher"].endswith("mvn") + assert status["hosts"][0] == "my-org/my-repo" + + +# --------------------------------------------------------------------------- +# CLI wiring +# --------------------------------------------------------------------------- + + +def test_install_requires_org_and_repo(): + result = CliRunner().invoke(install_cmd, ["maven"], catch_exceptions=False) + + assert result.exit_code == 1 + assert "requires --org and --repo" in result.output + + +def test_shell_init_prints_the_shims_directory(): + result = CliRunner().invoke(shell_init, ["--shell", "bash"]) + + assert result.exit_code == 0 + assert str(config.shims_dir()) in result.output + assert "export PATH=" in result.output diff --git a/cloudsmith_cli/cli/tests/commands/test_default_domains.py b/cloudsmith_cli/cli/tests/commands/test_default_domains.py index 20010697..86376330 100644 --- a/cloudsmith_cli/cli/tests/commands/test_default_domains.py +++ b/cloudsmith_cli/cli/tests/commands/test_default_domains.py @@ -9,6 +9,8 @@ BUILTIN_DOMAINS, DefaultDomain, DomainType, + default_host, + default_host_for_type, load_default_domains, untrusted_config_declares_domains, ) @@ -187,3 +189,59 @@ def test_untrusted_cwd_config_is_not_honoured(tmp_path, monkeypatch): assert all(domain.host != "evil.example.com" for domain in domains) assert untrusted_config_declares_domains() is True + + +def test_default_host_resolves_backend_kind(no_trusted_config): + """With no override the table is the built-in one.""" + assert default_host(BackendKind.MAVEN) == "maven.cloudsmith.io" + assert default_host(BackendKind.PYTHON) == "python.cloudsmith.io" + + +def test_default_host_rejects_kind_without_dedicated_host(no_trusted_config): + """Formats served only via the CDN have no dedicated host to resolve.""" + with pytest.raises(ValueError): + default_host(BackendKind.DEB) + + +def test_default_host_for_type_resolves_download_and_upload(no_trusted_config): + """The download/upload hosts are looked up by type, not by constant.""" + assert default_host_for_type(DomainType.DOWNLOAD) == "dl.cloudsmith.io" + assert default_host_for_type(DomainType.UPLOAD) == "upload.cloudsmith.io" + + +def test_default_host_for_type_rejects_ambiguous_native_api(no_trusted_config): + """NATIVE_API covers many hosts, so there is no single one to return.""" + with pytest.raises(ValueError): + default_host_for_type(DomainType.NATIVE_API) + + +def test_default_hosts_honour_a_config_override(tmp_path, monkeypatch): + """default_host/_for_type resolve against the override, not the builtins.""" + (tmp_path / "config.ini").write_text( + "[domains]\n" + "cdn.internal.example.com = download\n" + "mvn.internal.example.com = maven\n", + encoding="utf-8", + ) + monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"]) + monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", [str(tmp_path)]) + + assert default_host_for_type(DomainType.DOWNLOAD) == "cdn.internal.example.com" + assert default_host(BackendKind.MAVEN) == "mvn.internal.example.com" + + +def test_default_host_raises_when_the_override_omits_it(tmp_path, monkeypatch): + """An override that omits a kind must not resolve to the public host. + + A declared table replaces the built-ins wholesale, so falling back would + hand a dedicated deployment `maven.cloudsmith.io` - publishing its + artifacts, and its token, to a host the operator never listed. + """ + (tmp_path / "config.ini").write_text( + "[domains]\ncdn.internal.example.com = download\n", encoding="utf-8" + ) + monkeypatch.setattr(cli_config.ConfigReader, "config_files", ["config.ini"]) + monkeypatch.setattr(cli_config.ConfigReader, "config_searchpath", [str(tmp_path)]) + + with pytest.raises(ValueError): + default_host(BackendKind.MAVEN) diff --git a/cloudsmith_cli/credential_helpers/common.py b/cloudsmith_cli/credential_helpers/common.py index 17db3dca..f145574a 100644 --- a/cloudsmith_cli/credential_helpers/common.py +++ b/cloudsmith_cli/credential_helpers/common.py @@ -8,6 +8,7 @@ import logging from .custom_domains import get_custom_domains, get_format_domains +from .default_domains import load_default_domains logger = logging.getLogger(__name__) @@ -46,6 +47,45 @@ 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", ".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. Either way, a match here is the deployment's own service host - the + equivalent of ``dl.cloudsmith.io`` - not a genuinely discovered custom + domain. + """ + hostname = extract_hostname(url) + return any(domain.host.lower() == hostname for domain in load_default_domains()) + + +def repo_path_segment(owner, repo, host): + """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 discovered custom domain is bound to a single + org, so the org is omitted (````). This rule is Cloudsmith-wide, + not format-specific. + """ + if is_standard_cloudsmith_host(host) or is_default_host(host): + return f"{owner}/{repo}" + return repo + + def is_cloudsmith_domain( url, credential=None, api_host=None, backend_kind=None, org=None ): @@ -73,9 +113,7 @@ def is_cloudsmith_domain( return False # Standard Cloudsmith domains — no auth needed, always match regardless of backend_kind - if hostname in ("cloudsmith.io", "cloudsmith.com") or hostname.endswith( - (".cloudsmith.io", ".cloudsmith.com") - ): + if is_standard_cloudsmith_host(hostname): return True # Custom domains require org + auth diff --git a/cloudsmith_cli/credential_helpers/default_domains.py b/cloudsmith_cli/credential_helpers/default_domains.py index b56b7cf3..8fec3442 100644 --- a/cloudsmith_cli/credential_helpers/default_domains.py +++ b/cloudsmith_cli/credential_helpers/default_domains.py @@ -289,6 +289,39 @@ def load_default_domains(config_path: Path | str | None = None) -> list[DefaultD return domains +def _default_host(matches, described_as: str) -> str: + """Return the first effective default host satisfying `matches`. + + A declared ``[domains]`` table replaces the built-ins wholesale, so a host + it omits raises rather than resolving to the ``*.cloudsmith.io`` one the + operator deliberately did not list - handing that back would publish a + dedicated deployment's artifacts, and its token, to the public service. + """ + for domain in load_default_domains(): + if matches(domain): + return domain.host + raise ValueError(f"No Cloudsmith host for {described_as}") + + +def default_host(backend_kind: int) -> str: + """Return the host for `backend_kind`, honouring a trusted override.""" + return _default_host( + lambda domain: domain.backend_kind == backend_kind, + f"backend kind {backend_kind}", + ) + + +def default_host_for_type(domain_type: DomainType) -> str: + """Return the host of `domain_type`, honouring a trusted override.""" + if domain_type is DomainType.NATIVE_API: + raise ValueError( + "NATIVE_API is served by many hosts; resolve it with default_host()" + ) + return _default_host( + lambda domain: domain.domain_type is domain_type, f"type {domain_type.value}" + ) + + def untrusted_config_declares_domains() -> bool: """True if a directory-relative config.ini declares a [domains] section. diff --git a/cloudsmith_cli/credential_helpers/docker/installer.py b/cloudsmith_cli/credential_helpers/docker/installer.py index 818812d8..81f9f826 100644 --- a/cloudsmith_cli/credential_helpers/docker/installer.py +++ b/cloudsmith_cli/credential_helpers/docker/installer.py @@ -11,14 +11,19 @@ import json import logging import os -import sys from pathlib import Path from ...core.cache_utils import merge_json_file from ...core.credentials.models import CredentialResult from ..backends import BackendKind from ..custom_domains import get_format_domains -from ..launchers import is_on_path, remove_launcher, resolve_bin_dir, write_launcher +from ..launchers import ( + cloudsmith_command, + is_on_path, + remove_launcher, + resolve_bin_dir, + write_launcher, +) logger = logging.getLogger(__name__) @@ -51,27 +56,12 @@ class DockerInstaller: """ LAUNCHER_NAME = "docker-credential-cloudsmith" - TARGET_CMD = "cloudsmith credential-helper docker" HELPER_VALUE = "cloudsmith" DEFAULT_HOST = "docker.cloudsmith.io" name = "docker" summary = "Docker credential helper for Cloudsmith registries" - - @classmethod - def _resolve_target_cmd(cls) -> str: - """Return the command the launcher forwards to. - - A pip/source install resolves the bare ``cloudsmith`` command via - ``PATH``. A frozen standalone binary (PyInstaller) is not guaranteed - to be on ``PATH`` under that name, so point the launcher at the - absolute executable instead — mirroring the frozen handling in - :func:`cloudsmith_cli.cli.commands.mcp._get_server_config`. The path - is quoted so a directory containing spaces still execs correctly. - """ - if getattr(sys, "frozen", False): - return f'"{sys.executable}" credential-helper docker' - return cls.TARGET_CMD + requires_repo = False def install( self, @@ -208,7 +198,9 @@ def mutate(config: dict) -> None: # Real install launcher_path = write_launcher( - target_dir, self.LAUNCHER_NAME, self._resolve_target_cmd() + target_dir, + self.LAUNCHER_NAME, + cloudsmith_command("credential-helper", "docker"), ) actions.append(f"wrote launcher {launcher_path}") diff --git a/cloudsmith_cli/credential_helpers/launchers.py b/cloudsmith_cli/credential_helpers/launchers.py index acf978ef..541d8b98 100644 --- a/cloudsmith_cli/credential_helpers/launchers.py +++ b/cloudsmith_cli/credential_helpers/launchers.py @@ -24,11 +24,33 @@ def _is_windows() -> bool: return os.name == "nt" +def is_frozen() -> bool: + """Return True when running from a frozen standalone build (PyInstaller).""" + return getattr(sys, "frozen", False) + + +def cloudsmith_command(*args: str) -> str: + """Return the ``cloudsmith`` command line a launcher forwards to. + + A pip/source install resolves the bare ``cloudsmith`` command via ``PATH``. + A frozen standalone build is not guaranteed to be on ``PATH`` under that + name, so it is addressed by its absolute executable instead — quoted, so a + directory containing spaces still execs correctly. + """ + executable = f'"{sys.executable}"' if is_frozen() else "cloudsmith" + return " ".join((executable, *args)) + + def _launcher_filename(name: str, *, windows: bool) -> str: """Return the launcher file name for the platform (``.cmd`` on Windows).""" return f"{name}.cmd" if windows else name +def launcher_filename(name: str) -> str: + """Return the launcher file name *name* is written under on this platform.""" + return _launcher_filename(name, windows=_is_windows()) + + def _launcher_content(target_cmd: str, *, windows: bool) -> str: """Return the launcher script body for the platform. @@ -101,7 +123,7 @@ def remove_launcher(bin_dir: Path, name: str) -> bool: bool ``True`` if a file was removed, ``False`` if no file was found. """ - target = Path(bin_dir) / _launcher_filename(name, windows=_is_windows()) + target = Path(bin_dir) / launcher_filename(name) if target.exists(): target.unlink() diff --git a/cloudsmith_cli/credential_helpers/maven/__init__.py b/cloudsmith_cli/credential_helpers/maven/__init__.py new file mode 100644 index 00000000..7951ee24 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/__init__.py @@ -0,0 +1,2 @@ +# Copyright 2026 Cloudsmith Ltd +"""Maven credential support (mvn shim, settings.xml injection, installer).""" diff --git a/cloudsmith_cli/credential_helpers/maven/config.py b/cloudsmith_cli/credential_helpers/maven/config.py new file mode 100644 index 00000000..87a1b638 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/config.py @@ -0,0 +1,122 @@ +# Copyright 2026 Cloudsmith Ltd +"""Persistent state for the Maven credential helper. + +Records the repository wrapped ``mvn`` runs authenticate against, as a +``[maven]`` section in ``package-managers.ini`` inside the CLI config directory +(alongside ``config.ini`` / ``credentials.ini``). +""" + +from __future__ import annotations + +import configparser +import logging +from dataclasses import asdict, dataclass, field, fields +from pathlib import Path + +import click + +from ...cli.config import get_default_config_path +from ..backends import BackendKind +from ..default_domains import DomainType, default_host, default_host_for_type + +DEFAULT_SERVER_ID = "cloudsmith" + +_SECTION = "maven" + +logger = logging.getLogger(__name__) + + +def default_download_host() -> str: + """Return the download-CDN host, honouring a trusted [domains] override. + + Resolved per call rather than at import: a dedicated 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 Binding: + """The repository and hosts wrapped ``mvn`` runs are bound to.""" + + owner: str = "" + repo: str = "" + download_host: str = field(default_factory=default_download_host) + upload_host: str = field(default_factory=default_upload_host) + server_id: str = DEFAULT_SERVER_ID + + +def config_path() -> Path: + """Return the path to ``package-managers.ini`` in the CLI config dir.""" + return Path(get_default_config_path()) / "package-managers.ini" + + +def shims_dir() -> Path: + """Return the directory that holds package-manager shims on PATH.""" + return Path(get_default_config_path()) / "shims" + + +def _read() -> configparser.ConfigParser: + """Return the parsed config, or an empty one when it cannot be read. + + A hand-edited ``package-managers.ini`` must not break every wrapped run: + the shim intercepts every ``mvn`` on the machine, so letting a parse error + escape would make Maven unusable machine-wide rather than merely + unconfigured. An unreadable file reads as no binding, which the runner + already reports with the command needed to fix it. + """ + parser = configparser.ConfigParser(interpolation=None) + path = config_path() + if not path.exists(): + return parser + try: + parser.read(path, encoding="utf-8") + except (OSError, UnicodeDecodeError, configparser.Error) as exc: + logger.warning("Ignoring unreadable %s: %s", path, exc) + return configparser.ConfigParser(interpolation=None) + return parser + + +def get_binding() -> Binding | None: + """Return the stored binding, or ``None`` when Maven is not installed.""" + parser = _read() + if not parser.has_section(_SECTION): + return None + section = parser[_SECTION] + return Binding( + **{ + name: section[name] + for name in (f.name for f in fields(Binding)) + if section.get(name) + } + ) + + +def set_binding(binding: Binding) -> None: + """Record (or replace) the stored binding and persist it.""" + parser = _read() + parser[_SECTION] = asdict(binding) + _write(parser) + + +def remove_binding() -> bool: + """Drop the stored binding; return True if there was one.""" + parser = _read() + if not parser.has_section(_SECTION): + return False + parser.remove_section(_SECTION) + _write(parser) + return True + + +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) diff --git a/cloudsmith_cli/credential_helpers/maven/installer.py b/cloudsmith_cli/credential_helpers/maven/installer.py new file mode 100644 index 00000000..910a30b1 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/installer.py @@ -0,0 +1,262 @@ +# Copyright 2026 Cloudsmith Ltd +"""Installer for the Maven credential helper. + +Writes an ``mvn`` shim that re-execs ``cloudsmith exec -- mvn "$@"`` into the +Cloudsmith shims dir, records the repository binding, and prints the +``distributionManagement`` snippet needed for ``mvn deploy``. Dependency +resolution works transparently once the shims dir is on PATH (via +``credential-helper shell-init``); publishing is opt-in. + +Maven uses two distinct endpoints, so two custom-domain kinds matter: +- **download** (dependency resolution) goes via the download CDN; its custom + domains carry ``backend_kind is None`` (the generic download domain). +- **upload** (``distributionManagement``) goes via the native Maven endpoint; + its custom domains carry ``BackendKind.MAVEN``. +""" + +from __future__ import annotations + +import logging +import shutil +from xml.sax.saxutils import escape + +from ...core.api.exceptions import ApiException +from ...core.credentials.models import CredentialResult +from ...templates import render +from ..backends import BackendKind +from ..custom_domains import CustomDomain, get_custom_domains, order_by_precedence +from ..default_domains import DomainType +from ..launchers import ( + cloudsmith_command, + is_frozen, + is_on_path, + launcher_filename, + remove_launcher, + write_launcher, +) +from . import config +from .runner import BINARY_NAMES +from .settings import upload_url + +logger = logging.getLogger(__name__) + +BINARY_NAME = BINARY_NAMES[0] + +_DISTRIBUTION_MANAGEMENT_TEMPLATE = "maven_distribution_management.xml.tmpl" + +_USER_SETTINGS_NOTE = ( + "note: wrapped mvn runs use a generated 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(binding: config.Binding) -> str: + """Return the pom.xml distributionManagement snippet for opt-in deploy.""" + snippet = render( + _DISTRIBUTION_MANAGEMENT_TEMPLATE, + server_id=escape(binding.server_id), + url=escape(upload_url(binding.owner, binding.repo, binding.upload_host)), + ) + return ( + "To publish with `mvn deploy`, add this to your pom.xml " + "(the id must match the server id):\n" + snippet + ) + + +def _discover_domains( + org: str | None, + credential: CredentialResult | None, + api_host: str | None, + refresh: bool, + actions: list[str], +) -> list[CustomDomain]: + """Fetch the org's custom domains (best-effort; failure → WARNING + []). + + The lookup is strict so a failure arrives here as an ApiException rather + than an empty list: an unreachable API must not read as "this org has no + custom domains" and silently bind the install to the default hosts. + """ + if not (org and credential): + return [] + try: + return get_custom_domains( + org, credential=credential, api_host=api_host, refresh=refresh, strict=True + ) + except ApiException as exc: + actions.append(f"WARNING: custom-domain discovery failed: {exc}") + return [] + + +def _select_host( + domains: list[CustomDomain], + backend_kind: int | None, + domain_type: DomainType, + default_host, +) -> str: + """Return the host to bind for one endpoint, or the default. + + The backend kind alone does not identify an endpoint — the download CDN + and the generic upload endpoint both carry ``backend_kind is None`` — so + the domain type is matched too. Domains bound to a single repository are + left out: they need URLs of a different shape, which is its own change. + Candidates are ranked the way the server ranks overlapping domains, so two + installs of the same repository agree regardless of discovery order. + """ + eligible = [ + domain + for domain in domains + if domain.backend_kind == backend_kind + and domain.domain_type is domain_type + and domain.is_active + and not domain.repository + ] + ordered = order_by_precedence(eligible) + return ordered[0].host if ordered else default_host() + + +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, so PATH is irrelevant to it. + """ + return not is_frozen() and shutil.which("cloudsmith") is None + + +class MavenInstaller: + """Installs the Maven credential helper (shim + config entry).""" + + name = "maven" + summary = "Maven credential helper for Cloudsmith repositories" + requires_repo = True + + def install( + self, + *, + discover: bool = True, + refresh: bool = False, + org: str | None = None, + repo: str | None = None, + server_id: str = config.DEFAULT_SERVER_ID, + credential: CredentialResult | None = None, + api_host: str | None = None, + dry_run: bool = False, + ) -> list[str]: + """Install the Maven credential helper; return readable actions.""" + actions: list[str] = [] + discovered = ( + _discover_domains(org, credential, api_host, refresh, actions) + if discover + else [] + ) + binding = config.Binding( + owner=org or "", + repo=repo or "", + download_host=_select_host( + discovered, None, DomainType.DOWNLOAD, config.default_download_host + ), + upload_host=_select_host( + discovered, + BackendKind.MAVEN, + DomainType.NATIVE_API, + config.default_upload_host, + ), + server_id=server_id, + ) + description = ( + f"maven for {binding.owner}/{binding.repo} " + f"(download {binding.download_host}, upload {binding.upload_host})" + ) + + if dry_run: + actions.append(f"would write shim {_shim_path()}") + actions.append(f"would configure {description}") + else: + actions.extend(self._configure(binding, description)) + + actions.append(_USER_SETTINGS_NOTE) + actions.append(_deploy_snippet(binding)) + return actions + + def _configure(self, binding: config.Binding, description: str) -> list[str]: + """Persist the binding, write the shim, and warn about the setup.""" + # The binding is persisted first: the shim intercepts every `mvn` on + # the machine and refuses to run one it has no binding for, so a shim + # written ahead of a failed set_binding would leave Maven unusable + # rather than merely uninstalled. + config.set_binding(binding) + actions = [f"configured {description}"] + + shims_dir = config.shims_dir() + actions.append( + "wrote shim " + + str( + write_launcher( + shims_dir, + BINARY_NAME, + cloudsmith_command("exec", "--", BINARY_NAME), + ) + ) + ) + + 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" + ) + return actions + + def uninstall(self, *, dry_run: bool = False) -> list[str]: + """Remove the Maven shim and drop its config entry.""" + shim = _shim_path() + shim_absent = f"shim not found at {shim} (nothing to remove)" + not_configured = "maven not configured (nothing to remove)" + if dry_run: + return [ + f"would remove shim {shim}" if shim.exists() else shim_absent, + ( + "would remove maven from the package-manager config" + if config.get_binding() is not None + else not_configured + ), + ] + return [ + ( + f"removed shim {shim}" + if remove_launcher(config.shims_dir(), BINARY_NAME) + else shim_absent + ), + ( + "removed maven from the package-manager config" + if config.remove_binding() + else not_configured + ), + ] + + def status(self) -> dict: + """Return shim path (str|None) and configured hosts for `list`.""" + shim = _shim_path() + binding = config.get_binding() + hosts = ( + [ + f"{binding.owner}/{binding.repo}", + f"download:{binding.download_host}", + f"upload:{binding.upload_host}", + ] + if binding is not None + else [] + ) + return {"launcher": str(shim) if shim.exists() else None, "hosts": hosts} + + +def _shim_path(): + """Return the path the ``mvn`` shim is written to on this platform.""" + return config.shims_dir() / launcher_filename(BINARY_NAME) diff --git a/cloudsmith_cli/credential_helpers/maven/runner.py b/cloudsmith_cli/credential_helpers/maven/runner.py new file mode 100644 index 00000000..2deb45b7 --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/runner.py @@ -0,0 +1,181 @@ +# Copyright 2026 Cloudsmith Ltd +"""Run a command with Cloudsmith credentials provisioned for it. + +``cloudsmith exec -- `` (or the ``mvn`` shim, which forwards to it) +lands here. A Maven command is run against a generated ``settings.xml`` that +is deleted when the run ends; anything else runs unchanged. + +The real binary is resolved with the shims directory excluded, so a shim never +re-invokes itself. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tempfile + +from . import config, settings + +BINARY_NAMES = ("mvn", "mvnw") + +# Flags that short-circuit Maven wherever they appear, so there is nothing to +# authenticate. +_SKIP_AUTH_ARGS = frozenset({"--help", "-h", "--version", "-v", "help"}) + +_SETTINGS_ARGS = ("-s", "--settings", "-settings") + +# Maven's parser accepts long options with a single dash, so these are not the +# attached form of -s (``-smysettings.xml``) despite starting with it. +_NON_SETTINGS_S_OPTIONS = ("-show-version", "-strict-checksums") + +_NOT_CONFIGURED = ( + "cloudsmith: maven is not configured; run `cloudsmith credential-helper " + "install maven --org --repo `" +) + + +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_dir: str) -> str | None: + """Return the first ``$PATH`` match for *binary_name* outside *exclude_dir*. + + Comparison is by real path: a PATH entry that is a symlink to the shims + dir, or a symlink pointing at a shim, is excluded just the same. + """ + excluded = _canonical(exclude_dir) + for entry in os.environ.get("PATH", "").split(os.pathsep): + if not entry or _canonical(entry) == excluded: + continue + candidate = shutil.which(binary_name, path=entry) + if candidate and os.path.dirname(_canonical(candidate)) != excluded: + return candidate + return None + + +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 let our injected ``-s`` shadow the user's file. + """ + return any( + arg in _SETTINGS_ARGS + or arg.startswith(("--settings=", "-settings=")) + or ( + arg.startswith("-s") and len(arg) > 2 and arg not in _NON_SETTINGS_S_OPTIONS + ) + for arg in args + ) + + +def _run(path: str, args: list[str]) -> int: + """Run *path* with *args*, returning its exit code. + + A child killed by a signal comes back as a negative returncode, which is + not an exit status: passing it to ``sys.exit`` would truncate it to the low + 8 bits (SIGKILL becoming 247 rather than 137). It is translated to the + shell's ``128 + signal`` convention so callers can tell an OOM kill from an + ordinary build failure. + """ + completed = subprocess.run([path, *args], check=False) + if completed.returncode < 0: + return 128 - completed.returncode + return completed.returncode + + +def _wants_credentials(binary_name: str, args: list[str]) -> bool: + """True when this invocation should be handed a generated settings.xml.""" + # A path-qualified command (`./mvnw`, `/usr/local/bin/mvn`) has to be + # matched on its file name, or it silently runs with no credentials. + if os.path.basename(binary_name) not in BINARY_NAMES: + return False + if set(args) & _SKIP_AUTH_ARGS: + return False + if supplies_settings(args): + # Prepending our own -s as well would silently shadow the user's file: + # Maven takes the first occurrence. + print( + "cloudsmith: warning: the command supplies its own -s/--settings " + "file; running mvn without Cloudsmith credential injection", + file=sys.stderr, + ) + return False + return True + + +def _usable_binding() -> config.Binding | None: + """Return the binding to run under, or None after reporting why not. + + The shim wraps every ``mvn`` on the machine, so a missing or unusable + binding has to be reported as a message rather than raised through the + tool the user was actually trying to run. + """ + try: + binding = config.get_binding() + except ValueError as exc: + # A trusted [domains] table that declares no host for Maven has no + # default to fall back on. + print(f"cloudsmith: {exc}", file=sys.stderr) + return None + if binding is None or not (binding.owner and binding.repo): + print(_NOT_CONFIGURED, file=sys.stderr) + return None + return binding + + +def run(command: list[str], credential=None) -> int: + """Run *command*, injecting Cloudsmith credentials when it is Maven. + + 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, str(config.shims_dir())) + if real_binary is None: + print(f"cloudsmith: command not found: {binary_name}", file=sys.stderr) + return 127 + + if not _wants_credentials(binary_name, args): + return _run(real_binary, args) + + binding = _usable_binding() + if binding is None: + return 2 + return _run_with_settings(binding, credential, real_binary, args) + + +def _run_with_settings(binding, credential, real_binary: str, args: list[str]) -> int: + """Run Maven against a generated ``settings.xml``, deleted afterwards.""" + 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, + ) + + temp_dir = tempfile.mkdtemp(prefix="cloudsmith-maven-") + try: + path = settings.write_settings( + temp_dir, settings.build_settings_xml(binding, token or "") + ) + except OSError as exc: + # A failed provisioning must not crash the wrapped tool with a + # traceback. + shutil.rmtree(temp_dir, ignore_errors=True) + print(f"cloudsmith: failed to provision credentials: {exc}", file=sys.stderr) + return 1 + try: + return _run(real_binary, ["-s", path, *args]) + finally: + shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/cloudsmith_cli/credential_helpers/maven/settings.py b/cloudsmith_cli/credential_helpers/maven/settings.py new file mode 100644 index 00000000..9b1b45fb --- /dev/null +++ b/cloudsmith_cli/credential_helpers/maven/settings.py @@ -0,0 +1,70 @@ +# Copyright 2026 Cloudsmith Ltd +"""The ``settings.xml`` a wrapped ``mvn`` run is handed. + +Maven has no credential-helper protocol, so credentials are injected as a +generated ``settings.xml`` passed with ``mvn -s`` rather than written into +``~/.m2``. It carries an active profile, authored entirely by us, whose +repositories point at the Cloudsmith download CDN — so dependency resolution +works with no ``pom.xml`` edits — and one ```` holding the token. + +That server's id is the same one ``distributionManagement`` names for +publishing, so it is deliberately stable and guessable (``cloudsmith`` by +default, or ``--server-id``): a team shares one ``pom.xml``. Maven matches a +server's credentials to a repository by id alone, with no host check, so a +``pom.xml`` declaring a repository under that id receives the token — the same +exposure as the ``~/.m2/settings.xml`` every Maven user already keeps. Set +``--server-id`` to something unguessable to close it. +""" + +from __future__ import annotations + +import os +from xml.sax.saxutils import escape + +from ...templates import render +from ..common import repo_path_segment +from .config import Binding + +SETTINGS_FILENAME = "settings.xml" + +_SETTINGS_TEMPLATE = "maven_settings.xml.tmpl" + + +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, host: str) -> str: + """Return the Maven download (dependency-resolution) repository URL.""" + return _cloudsmith_url(host, "basic", repo_path_segment(owner, repo, host), "maven") + + +def upload_url(owner: str, repo: str, host: str) -> str: + """Return the native Maven upload (distributionManagement) URL.""" + return _cloudsmith_url(host, repo_path_segment(owner, repo, host)) + + +def build_settings_xml(binding: Binding, token: str) -> str: + """Return the ``settings.xml`` body for *binding* and *token*.""" + return render( + _SETTINGS_TEMPLATE, + server_id=escape(binding.server_id), + token=escape(token), + url=escape(download_url(binding.owner, binding.repo, binding.download_host)), + ) + + +def write_settings(directory: str, content: str) -> str: + """Write *content* as a mode-0600 ``settings.xml`` in *directory*. + + Opened with the mode applied on creation, so the token is never briefly + world-readable — an ``open()`` at the process umask followed by ``chmod`` + leaves a window where it is. + """ + path = os.path.join(directory, SETTINGS_FILENAME) + descriptor = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(content) + return path 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..4a883cde --- /dev/null +++ b/cloudsmith_cli/templates/maven_settings.xml.tmpl @@ -0,0 +1,29 @@ + + + + + token + + + + + + + + + + + + + + + + + + + + + + + +