From 141bdcbfbd27d8b33ed8da3eadbccd1f63ac60e1 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Sat, 22 Aug 2026 09:41:03 +0100 Subject: [PATCH] feat(no-ticket): add CLOUDSMITH_KEYRING_FILE_PATH and CLOUDSMITH_KEYRING_DIR Route the bundled file-based keyring backends with CLI-specific env vars instead of XDG_DATA_HOME. CLOUDSMITH_KEYRING_FILE_PATH aliases KEYRING_PROPERTY_FILE_PATH and sets the exact storage file. CLOUDSMITH_KEYRING_DIR sets the storage directory and keeps the backend's default filename. Native keyring variables take precedence. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + cloudsmith_cli/core/keyring.py | 39 +++- cloudsmith_cli/core/tests/test_keyring.py | 224 ++++++++++++++++++++++ 3 files changed, 264 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8299bd..b261064b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- `CLOUDSMITH_KEYRING_FILE_PATH` is now accepted as an alias for `KEYRING_PROPERTY_FILE_PATH`, setting the exact file the bundled `keyrings.cryptfile`/`keyrings.alt` file backends store tokens in. If both are set, `KEYRING_PROPERTY_FILE_PATH` takes precedence. `~` and environment variables in the value are expanded. +- `CLOUDSMITH_KEYRING_DIR` points the bundled file-based keyring backends at a directory; each backend keeps its default filename. Previously the only way to relocate these files was `XDG_DATA_HOME` (or the platform equivalent), which moves data for every XDG-aware application. A file path variable (`CLOUDSMITH_KEYRING_FILE_PATH` or `KEYRING_PROPERTY_FILE_PATH`) takes precedence over the directory. `~` and environment variables in the value are expanded. - Added an PNPM credential helper for Cloudsmith registries. `cloudsmith credential-helper install pnpm` installs an `pnpm-credential-cloudsmith` launcher binary and registers it in `~/.npmrc`, so npm authenticates to Cloudsmith registries automatically using your existing CLI credentials — no manual `npm login` required. Custom Cloudsmith registry domains are discovered via the API and cached locally; add extra hostnames with `--domain` (repeatable), disable discovery with `--no-discover`, or preview changes with `--dry-run`. Manage installed helpers with `cloudsmith credential-helper uninstall pnpm` and `cloudsmith credential-helper list`. ## [1.24.0] - 2026-08-18 diff --git a/cloudsmith_cli/core/keyring.py b/cloudsmith_cli/core/keyring.py index 5e721098..cb46ff6e 100644 --- a/cloudsmith_cli/core/keyring.py +++ b/cloudsmith_cli/core/keyring.py @@ -38,6 +38,40 @@ def _sync_keyring_property_env(): os.environ["KEYRING_PROPERTY_KEYRING_KEY"] = alias_value +def _expand_path(value): + return os.path.expanduser(os.path.expandvars(value)) + + +def _sync_keyring_file_path_env(): + """Allow CLOUDSMITH_KEYRING_FILE_PATH to alias KEYRING_PROPERTY_FILE_PATH.""" + alias_value = os.environ.get("CLOUDSMITH_KEYRING_FILE_PATH") + if alias_value and "KEYRING_PROPERTY_FILE_PATH" not in os.environ: + os.environ["KEYRING_PROPERTY_FILE_PATH"] = _expand_path(alias_value) + + +def _apply_keyring_file_location(backend): + """Set the storage file location before other properties apply. + + The cryptfile keyring_key setter opens the storage file at + assignment time. Set the file location first, so the backend opens + the correct file. A file path env var takes precedence over + CLOUDSMITH_KEYRING_DIR. With the directory, the backend keeps its + default filename. The function ignores backends without a storage + file. + """ + file_path = os.environ.get("KEYRING_PROPERTY_FILE_PATH") + if file_path: + backend.file_path = file_path + return + dir_value = os.environ.get("CLOUDSMITH_KEYRING_DIR") + if not dir_value: + return + filename = getattr(backend, "filename", None) + if not filename: + return + backend.file_path = os.path.join(_expand_path(dir_value), filename) + + def _prepare_keyring_backend(): """Resolve env var aliases and apply them to the keyring backend. @@ -49,7 +83,10 @@ def _prepare_keyring_backend(): """ _sync_keyring_backend_env() _sync_keyring_property_env() - keyring.get_keyring().set_properties_from_env() + _sync_keyring_file_path_env() + backend = keyring.get_keyring() + _apply_keyring_file_location(backend) + backend.set_properties_from_env() def _get_value(key): diff --git a/cloudsmith_cli/core/tests/test_keyring.py b/cloudsmith_cli/core/tests/test_keyring.py index 15bd7a4c..4fd96e04 100644 --- a/cloudsmith_cli/core/tests/test_keyring.py +++ b/cloudsmith_cli/core/tests/test_keyring.py @@ -7,6 +7,7 @@ import pytest from freezegun import freeze_time from keyring.errors import KeyringError +from keyrings.cryptfile.cryptfile import CryptFileKeyring from ..keyring import ( delete_sso_tokens, @@ -348,6 +349,229 @@ def test_applies_properties_to_resolved_backend( mock_get_keyring.return_value.set_properties_from_env.assert_called_once() +class TestKeyringFilePathAlias: + """Tests for CLOUDSMITH_KEYRING_FILE_PATH aliasing KEYRING_PROPERTY_FILE_PATH.""" + + api_host = "https://example.com" + + def test_sets_keyring_property_when_unset(self, mock_get_user, mock_get_password): + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["CLOUDSMITH_KEYRING_FILE_PATH"] = "/secure/path/keyring.cfg" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert ( + os.environ["KEYRING_PROPERTY_FILE_PATH"] == "/secure/path/keyring.cfg" + ) + + def test_does_not_override_existing_keyring_property( + self, mock_get_user, mock_get_password + ): + env = os.environ.copy() + env["KEYRING_PROPERTY_FILE_PATH"] = "/native/path/keyring.cfg" + env["CLOUDSMITH_KEYRING_FILE_PATH"] = "/alias/path/keyring.cfg" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert ( + os.environ["KEYRING_PROPERTY_FILE_PATH"] == "/native/path/keyring.cfg" + ) + + def test_no_op_when_alias_not_set(self, mock_get_user, mock_get_password): + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env.pop("CLOUDSMITH_KEYRING_FILE_PATH", None) + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert "KEYRING_PROPERTY_FILE_PATH" not in os.environ + + def test_expands_user_and_env_vars(self, mock_get_user, mock_get_password): + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["KEYRING_STORE_ROOT"] = "/secure" + env["CLOUDSMITH_KEYRING_FILE_PATH"] = "$KEYRING_STORE_ROOT/keyring.cfg" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert os.environ["KEYRING_PROPERTY_FILE_PATH"] == "/secure/keyring.cfg" + + def test_expands_home_directory(self, mock_get_user, mock_get_password): + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["CLOUDSMITH_KEYRING_FILE_PATH"] = "~/keyring.cfg" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert os.environ["KEYRING_PROPERTY_FILE_PATH"] == os.path.expanduser( + "~/keyring.cfg" + ) + + def test_expands_home_inside_env_var_value(self, mock_get_user, mock_get_password): + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["KEYRING_STORE_ROOT"] = "~" + env["CLOUDSMITH_KEYRING_FILE_PATH"] = "$KEYRING_STORE_ROOT/keyring.cfg" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert os.environ["KEYRING_PROPERTY_FILE_PATH"] == os.path.expanduser( + "~/keyring.cfg" + ) + + +class TestKeyringDirEnv: + """Tests for CLOUDSMITH_KEYRING_DIR redirecting file-backed backends.""" + + api_host = "https://example.com" + + def test_sets_backend_file_path( + self, mock_get_user, mock_get_password, mock_get_keyring + ): + backend = mock_get_keyring.return_value + backend.filename = "cryptfile_pass.cfg" + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["CLOUDSMITH_KEYRING_DIR"] = "/secure/dir" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert backend.file_path == os.path.join( + "/secure/dir", "cryptfile_pass.cfg" + ) + + def test_native_file_path_wins_over_dir( + self, mock_get_user, mock_get_password, mock_get_keyring + ): + backend = mock_get_keyring.return_value + backend.filename = "cryptfile_pass.cfg" + env = os.environ.copy() + env["KEYRING_PROPERTY_FILE_PATH"] = "/native/path/keyring.cfg" + env["CLOUDSMITH_KEYRING_DIR"] = "/secure/dir" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert backend.file_path == "/native/path/keyring.cfg" + + def test_alias_file_path_wins_over_dir( + self, mock_get_user, mock_get_password, mock_get_keyring + ): + backend = mock_get_keyring.return_value + backend.filename = "cryptfile_pass.cfg" + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["CLOUDSMITH_KEYRING_FILE_PATH"] = "/alias/path/keyring.cfg" + env["CLOUDSMITH_KEYRING_DIR"] = "/secure/dir" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert backend.file_path == "/alias/path/keyring.cfg" + + def test_no_op_for_backends_without_filename( + self, mock_get_user, mock_get_password, mock_get_keyring + ): + backend = mock_get_keyring.return_value + del backend.filename + backend.file_path = "untouched" + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["CLOUDSMITH_KEYRING_DIR"] = "/secure/dir" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert backend.file_path == "untouched" + + def test_no_op_when_dir_not_set( + self, mock_get_user, mock_get_password, mock_get_keyring + ): + backend = mock_get_keyring.return_value + backend.filename = "cryptfile_pass.cfg" + backend.file_path = "untouched" + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env.pop("CLOUDSMITH_KEYRING_DIR", None) + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert backend.file_path == "untouched" + + def test_expands_user_and_env_vars( + self, mock_get_user, mock_get_password, mock_get_keyring + ): + backend = mock_get_keyring.return_value + backend.filename = "keyring_pass.cfg" + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["KEYRING_STORE_ROOT"] = "/secure" + env["CLOUDSMITH_KEYRING_DIR"] = "$KEYRING_STORE_ROOT/store" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert backend.file_path == os.path.join( + "/secure/store", "keyring_pass.cfg" + ) + + def test_expands_home_directory( + self, mock_get_user, mock_get_password, mock_get_keyring + ): + backend = mock_get_keyring.return_value + backend.filename = "keyring_pass.cfg" + env = os.environ.copy() + env.pop("KEYRING_PROPERTY_FILE_PATH", None) + env["CLOUDSMITH_KEYRING_DIR"] = "~/store" + with patch.dict(os.environ, env, clear=True): + get_access_token(self.api_host) + assert backend.file_path == os.path.join( + os.path.expanduser("~/store"), "keyring_pass.cfg" + ) + + +class TestKeyringFileRelocation: + """Tests for file relocation with a real cryptfile backend.""" + + api_host = "https://example.com" + + @staticmethod + def _relocation_env(tmp_path): + env = os.environ.copy() + for var in ( + "KEYRING_PROPERTY_FILE_PATH", + "KEYRING_PROPERTY_KEYRING_KEY", + "CLOUDSMITH_KEYRING_FILE_PATH", + "CLOUDSMITH_KEYRING_DIR", + "CLOUDSMITH_KEYRING_KEY", + ): + env.pop(var, None) + env["XDG_DATA_HOME"] = str(tmp_path / "default") + env["CLOUDSMITH_KEYRING_KEY"] = "test-password" + return env + + def _roundtrip_token(self, backend, env): + with ( + patch.dict(os.environ, env, clear=True), + patch.object(keyring, "set_password", side_effect=backend.set_password), + patch.object(keyring, "get_password", side_effect=backend.get_password), + ): + store_access_token(self.api_host, "token-value") + assert get_access_token(self.api_host) == "token-value" + + def test_dir_relocates_storage_before_unlock( + self, tmp_path, mock_get_user, mock_get_keyring + ): + backend = CryptFileKeyring() + mock_get_keyring.return_value = backend + env = self._relocation_env(tmp_path) + env["CLOUDSMITH_KEYRING_DIR"] = str(tmp_path / "secure") + + self._roundtrip_token(backend, env) + + assert (tmp_path / "secure" / backend.filename).is_file() + assert not (tmp_path / "default").exists() + + def test_file_path_alias_relocates_storage_before_unlock( + self, tmp_path, mock_get_user, mock_get_keyring + ): + backend = CryptFileKeyring() + mock_get_keyring.return_value = backend + target = tmp_path / "secure" / "tokens.cfg" + env = self._relocation_env(tmp_path) + env["CLOUDSMITH_KEYRING_FILE_PATH"] = str(target) + + self._roundtrip_token(backend, env) + + assert target.is_file() + assert not (tmp_path / "default").exists() + + class TestDeleteSsoTokens: """Tests for the delete_sso_tokens and has_sso_tokens functions."""