From fc50fa17e2b9a9fa2df614cd2ac9883ab00d180f Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Tue, 21 Oct 2025 14:21:09 -0600 Subject: [PATCH 01/19] Create import error class --- cloudinit/sources/DataSourceAzure.py | 6 ++---- cloudinit/sources/azure/errors.py | 7 +++++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index 00536244b20..31aabf69d7a 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -60,16 +60,14 @@ import passlib.hash blowfish_hash = passlib.hash.sha512_crypt.hash - except ImportError: + except ImportError as error: def blowfish_hash(_): """Raise when called so that importing this module doesn't throw ImportError when ds_detect() returns false. In this case, crypt and passlib are not needed. """ - raise ImportError( - "crypt and passlib not found, missing dependency" - ) + raise errors.ReportableErrorImportError(exception=error) LOG = logging.getLogger(__name__) diff --git a/cloudinit/sources/azure/errors.py b/cloudinit/sources/azure/errors.py index ae676a08d29..d723d91f22c 100644 --- a/cloudinit/sources/azure/errors.py +++ b/cloudinit/sources/azure/errors.py @@ -168,6 +168,13 @@ def __init__(self, *, exception: ValueError) -> None: self.supporting_data["exception"] = repr(exception) +class ReportableErrorImportError(ReportableError): + def __init__(self, *, exception: ImportError) -> None: + super().__init__("error importing library") + + self.supporting_data["exception"] = repr(exception) + + class ReportableErrorOsDiskPpsFailure(ReportableError): def __init__(self) -> None: super().__init__("error waiting for host shutdown") From e5accafdf47d6e5308230fd5ffbb05ea029d7a63 Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Tue, 2 Dec 2025 09:23:14 -0800 Subject: [PATCH 02/19] Update to use error instead of exception --- cloudinit/sources/DataSourceAzure.py | 2 +- cloudinit/sources/azure/errors.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index 31aabf69d7a..233c7beb612 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -67,7 +67,7 @@ def blowfish_hash(_): ImportError when ds_detect() returns false. In this case, crypt and passlib are not needed. """ - raise errors.ReportableErrorImportError(exception=error) + raise errors.ReportableErrorImportError(error=error) LOG = logging.getLogger(__name__) diff --git a/cloudinit/sources/azure/errors.py b/cloudinit/sources/azure/errors.py index d723d91f22c..56e14a06494 100644 --- a/cloudinit/sources/azure/errors.py +++ b/cloudinit/sources/azure/errors.py @@ -169,10 +169,10 @@ def __init__(self, *, exception: ValueError) -> None: class ReportableErrorImportError(ReportableError): - def __init__(self, *, exception: ImportError) -> None: + def __init__(self, *, error: ImportError) -> None: super().__init__("error importing library") - self.supporting_data["exception"] = repr(exception) + self.supporting_data["error"] = repr(error) class ReportableErrorOsDiskPpsFailure(ReportableError): From bec4beed872644201baadd173c42e25e3f31438e Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Wed, 4 Feb 2026 11:47:54 -0800 Subject: [PATCH 03/19] Use a local import error --- cloudinit/sources/DataSourceAzure.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index 233c7beb612..ebb8116e8c9 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -60,14 +60,15 @@ import passlib.hash blowfish_hash = passlib.hash.sha512_crypt.hash - except ImportError as error: + except ImportError as e: + _import_error = e def blowfish_hash(_): """Raise when called so that importing this module doesn't throw ImportError when ds_detect() returns false. In this case, crypt and passlib are not needed. """ - raise errors.ReportableErrorImportError(error=error) + raise errors.ReportableErrorImportError(error=_import_error) LOG = logging.getLogger(__name__) From daf89ef130b45693fd5c56c1657dce4503d8f90f Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 08:11:18 -0800 Subject: [PATCH 04/19] Log the library name --- cloudinit/sources/DataSourceAzure.py | 4 ++-- cloudinit/sources/azure/errors.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index ebb8116e8c9..81c559680ea 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -60,8 +60,8 @@ import passlib.hash blowfish_hash = passlib.hash.sha512_crypt.hash - except ImportError as e: - _import_error = e + except ImportError as error: + _import_error = error def blowfish_hash(_): """Raise when called so that importing this module doesn't throw diff --git a/cloudinit/sources/azure/errors.py b/cloudinit/sources/azure/errors.py index 56e14a06494..8ef9515304b 100644 --- a/cloudinit/sources/azure/errors.py +++ b/cloudinit/sources/azure/errors.py @@ -170,7 +170,7 @@ def __init__(self, *, exception: ValueError) -> None: class ReportableErrorImportError(ReportableError): def __init__(self, *, error: ImportError) -> None: - super().__init__("error importing library") + super().__init__(f"error importing {error.name} library") self.supporting_data["error"] = repr(error) From 2224a9a8c603dd9910fcd00ba85c358865808218 Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 08:47:52 -0800 Subject: [PATCH 05/19] Create unit tests for import error --- tests/unittests/sources/azure/test_errors.py | 9 +++++++++ tests/unittests/sources/test_azure.py | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/tests/unittests/sources/azure/test_errors.py b/tests/unittests/sources/azure/test_errors.py index d9dcf13d555..7b405368d39 100644 --- a/tests/unittests/sources/azure/test_errors.py +++ b/tests/unittests/sources/azure/test_errors.py @@ -211,6 +211,15 @@ def test_imds_metadata_parsing_exception(): assert error.supporting_data["exception"] == repr(exception) +def test_import_error(): + exception = ImportError("No module named 'foobar'", name="foobar") + + error = errors.ReportableErrorImportError(error=exception) + + assert error.reason == "error importing foobar library" + assert error.supporting_data["error"] == repr(exception) + + def test_ovf_parsing_exception(): error = None try: diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 67373e1ef1e..ef19be4df12 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -2434,6 +2434,15 @@ def test_wb_invalid_ovf_env_xml_calls_read_azure_ovf(self, tmp_path): == cm.value.reason ) + def test_import_error_from_failed_import(): + """ Attempt to import a module that is not present""" + try: + import nonexistent_module_that_will_never_exist + except ImportError as error: + reportable_error = errors.ReportableErrorImportError(error=error) + + assert reportable_error.reason == "error importing nonexistent_module_that_will_never_exist library" + assert reportable_error.supporting_data["error"] == repr(error.value) class TestReadAzureOvf: def test_invalid_xml_raises_non_azure_ds(self): From 1f8f9e0662f06f1d46408f68189b48edced4ff05 Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 09:00:49 -0800 Subject: [PATCH 06/19] Correct unit test --- tests/unittests/sources/test_azure.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index ef19be4df12..3f9962fd21b 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -2434,15 +2434,21 @@ def test_wb_invalid_ovf_env_xml_calls_read_azure_ovf(self, tmp_path): == cm.value.reason ) - def test_import_error_from_failed_import(): - """ Attempt to import a module that is not present""" + def test_import_error_from_failed_import(self): + """Attempt to import a module that is not present""" try: - import nonexistent_module_that_will_never_exist + import nonexistent_module_that_will_never_exist # noqa: F401 except ImportError as error: reportable_error = errors.ReportableErrorImportError(error=error) - assert reportable_error.reason == "error importing nonexistent_module_that_will_never_exist library" - assert reportable_error.supporting_data["error"] == repr(error.value) + assert ( + reportable_error.reason == "error importing" + "nonexistent_module_that_will_never_exist library" + ) + assert reportable_error.supporting_data["error"] == repr( + error.value + ) + class TestReadAzureOvf: def test_invalid_xml_raises_non_azure_ds(self): From 279e3687ec906b0d1e398b0206d43ae795733d8b Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 09:04:19 -0800 Subject: [PATCH 07/19] Correct spacing --- tests/unittests/sources/test_azure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 3f9962fd21b..ddd6bc31418 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -2442,7 +2442,7 @@ def test_import_error_from_failed_import(self): reportable_error = errors.ReportableErrorImportError(error=error) assert ( - reportable_error.reason == "error importing" + reportable_error.reason == "error importing " "nonexistent_module_that_will_never_exist library" ) assert reportable_error.supporting_data["error"] == repr( From ce066788015429878698671bb148ab7a5d37cbcd Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 09:07:59 -0800 Subject: [PATCH 08/19] Ignore import not found --- tests/unittests/sources/test_azure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index ddd6bc31418..814bd5762ff 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -2437,7 +2437,7 @@ def test_wb_invalid_ovf_env_xml_calls_read_azure_ovf(self, tmp_path): def test_import_error_from_failed_import(self): """Attempt to import a module that is not present""" try: - import nonexistent_module_that_will_never_exist # noqa: F401 + import nonexistent_module_that_will_never_exist # noqa: F401 # type: ignore[import-not-found] except ImportError as error: reportable_error = errors.ReportableErrorImportError(error=error) @@ -2446,7 +2446,7 @@ def test_import_error_from_failed_import(self): "nonexistent_module_that_will_never_exist library" ) assert reportable_error.supporting_data["error"] == repr( - error.value + error ) From 1f9399be4a7b34c3e88864ac4eaed4b9729e704d Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 09:22:06 -0800 Subject: [PATCH 09/19] Reorder ignore comments --- tests/unittests/sources/test_azure.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 814bd5762ff..4643ec1603c 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -2437,7 +2437,7 @@ def test_wb_invalid_ovf_env_xml_calls_read_azure_ovf(self, tmp_path): def test_import_error_from_failed_import(self): """Attempt to import a module that is not present""" try: - import nonexistent_module_that_will_never_exist # noqa: F401 # type: ignore[import-not-found] + import nonexistent_module_that_will_never_exist # type: ignore[import-not-found] # noqa: F401 except ImportError as error: reportable_error = errors.ReportableErrorImportError(error=error) @@ -2445,9 +2445,7 @@ def test_import_error_from_failed_import(self): reportable_error.reason == "error importing " "nonexistent_module_that_will_never_exist library" ) - assert reportable_error.supporting_data["error"] == repr( - error - ) + assert reportable_error.supporting_data["error"] == repr(error) class TestReadAzureOvf: From d68ba4467184d6c0a133ba8861924208ca304a40 Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 09:24:57 -0800 Subject: [PATCH 10/19] Add isort skip --- tests/unittests/sources/test_azure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 4643ec1603c..99531c94c03 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -2437,7 +2437,7 @@ def test_wb_invalid_ovf_env_xml_calls_read_azure_ovf(self, tmp_path): def test_import_error_from_failed_import(self): """Attempt to import a module that is not present""" try: - import nonexistent_module_that_will_never_exist # type: ignore[import-not-found] # noqa: F401 + import nonexistent_module_that_will_never_exist # type: ignore[import-not-found] # noqa: F401 # isort:skip except ImportError as error: reportable_error = errors.ReportableErrorImportError(error=error) From 41d655f91d8d02489427c765cc2cabf140838a67 Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 09:26:49 -0800 Subject: [PATCH 11/19] Run black --- tests/unittests/sources/test_azure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 99531c94c03..bac6e48e4f1 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -2437,7 +2437,7 @@ def test_wb_invalid_ovf_env_xml_calls_read_azure_ovf(self, tmp_path): def test_import_error_from_failed_import(self): """Attempt to import a module that is not present""" try: - import nonexistent_module_that_will_never_exist # type: ignore[import-not-found] # noqa: F401 # isort:skip + import nonexistent_module_that_will_never_exist # type: ignore[import-not-found] # noqa: F401 # isort:skip except ImportError as error: reportable_error = errors.ReportableErrorImportError(error=error) From 26eb69fb1b2bb73797716c3e19d65aeabd7361ac Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 5 Feb 2026 15:59:46 -0800 Subject: [PATCH 12/19] Remove unnecessary _import_error --- cloudinit/sources/DataSourceAzure.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index 81c559680ea..233c7beb612 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -61,14 +61,13 @@ blowfish_hash = passlib.hash.sha512_crypt.hash except ImportError as error: - _import_error = error def blowfish_hash(_): """Raise when called so that importing this module doesn't throw ImportError when ds_detect() returns false. In this case, crypt and passlib are not needed. """ - raise errors.ReportableErrorImportError(error=_import_error) + raise errors.ReportableErrorImportError(error=error) LOG = logging.getLogger(__name__) From f9c9e204870ed010d5d76f0a706208ab794f3998 Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Tue, 10 Feb 2026 10:15:46 -0800 Subject: [PATCH 13/19] Move imports for password hashing --- cloudinit/sources/DataSourceAzure.py | 55 ++++++++++++------------- tests/unittests/sources/test_azure.py | 58 +++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/cloudinit/sources/DataSourceAzure.py b/cloudinit/sources/DataSourceAzure.py index 233c7beb612..a5a86deb45c 100644 --- a/cloudinit/sources/DataSourceAzure.py +++ b/cloudinit/sources/DataSourceAzure.py @@ -5,7 +5,6 @@ # This file is part of cloud-init. See LICENSE file for license information. import base64 -import functools import logging import os import os.path @@ -49,27 +48,6 @@ ) from cloudinit.url_helper import UrlError -try: - import crypt # pylint: disable=W4901 - - blowfish_hash: Any = functools.partial( - crypt.crypt, salt=f"$6${util.rand_str(strlen=16)}" - ) -except (ImportError, AttributeError): - try: - import passlib.hash - - blowfish_hash = passlib.hash.sha512_crypt.hash - except ImportError as error: - - def blowfish_hash(_): - """Raise when called so that importing this module doesn't throw - ImportError when ds_detect() returns false. In this case, crypt - and passlib are not needed. - """ - raise errors.ReportableErrorImportError(error=error) - - LOG = logging.getLogger(__name__) DS_NAME = "Azure" @@ -161,6 +139,33 @@ def find_dev_from_busdev(camcontrol_out: str, busdev: str) -> Optional[str]: return None +def hash_password(password: str) -> str: + """Hash a password using SHA-512 crypt. + + Try to use crypt, falling back to passlib. + + If neither are available, raise ReportableErrorImportError. + + :param password: plaintext password to hash. + :return: The hashed password string. + :raises ReportableErrorImportError: If crypt and passlib are unavailable. + """ + try: + import crypt # pylint: disable=W4901 + + salt = crypt.mksalt(crypt.METHOD_SHA512) + return crypt.crypt(password, salt) + except (ImportError, AttributeError): + pass + + try: + import passlib.hash + + return passlib.hash.sha512_crypt.hash(password) + except ImportError as error: + raise errors.ReportableErrorImportError(error=error) from error + + def normalize_mac_address(mac: str) -> str: """Normalize mac address with colons and lower-case.""" if len(mac) == 12: @@ -1979,7 +1984,7 @@ def read_azure_ovf(contents): if ovf_env.password: defuser["lock_passwd"] = False if DEF_PASSWD_REDACTION != ovf_env.password: - defuser["hashed_passwd"] = encrypt_pass(ovf_env.password) + defuser["hashed_passwd"] = hash_password(ovf_env.password) if defuser: cfg["system_info"] = {"default_user": defuser} @@ -2004,10 +2009,6 @@ def read_azure_ovf(contents): return (md, ud, cfg) -def encrypt_pass(password): - return blowfish_hash(password) - - def find_primary_nic(): candidate_nics = net.find_candidate_nics() if candidate_nics: diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index bac6e48e4f1..d4123614ff1 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -1,6 +1,7 @@ # This file is part of cloud-init. See LICENSE file for license information. # pylint: disable=attribute-defined-outside-init +import builtins import copy import datetime import json @@ -5713,3 +5714,60 @@ def test_query_vm_id_vm_id_conversion_failure( mock_query_system_uuid.assert_called_once() mock_convert_uuid.assert_called_once_with("test-system-uuid") + + +class TestHashPassword: + """Tests for the hash_password function.""" + + def test_crypt_working(self): + """Test that hash_password uses crypt when available.""" + mock_crypt = mock.MagicMock() + mock_crypt.METHOD_SHA512 = "sha512" + mock_crypt.mksalt.return_value = "$6$saltvalue" + mock_crypt.crypt.return_value = "$6$saltvalue$hashedpassword" + + with mock.patch.dict("sys.modules", {"crypt": mock_crypt}): + result = dsaz.hash_password("testpassword") + + mock_crypt.mksalt.assert_called_once_with("sha512") + mock_crypt.crypt.assert_called_once_with( + "testpassword", "$6$saltvalue" + ) + assert result == "$6$saltvalue$hashedpassword" + + def test_crypt_not_installed_passlib_fallback(self): + """Test that hash_password falls back to passlib when missing crypt.""" + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "crypt": + raise ImportError("No module named 'crypt'") + return real_import(name, *args, **kwargs) + + with mock.patch.object( + builtins, "__import__", side_effect=mock_import + ): + result = dsaz.hash_password("testpassword") + + # Verify we got a valid SHA-512 hash from passlib + assert result.startswith("$6$") + assert passlib.hash.sha512_crypt.verify("testpassword", result) + + def test_crypt_and_passlib_unavailable_raises_error(self): + """Test that hash_password raises ReportableErrorImportError.""" + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "crypt": + raise ImportError("No module named 'crypt'") + if name == "passlib.hash": + raise ImportError("No module named 'passlib'") + return real_import(name, *args, **kwargs) + + with mock.patch.object( + builtins, "__import__", side_effect=mock_import + ): + with pytest.raises(errors.ReportableErrorImportError) as exc_info: + dsaz.hash_password("testpassword") + + assert "passlib" in exc_info.value.reason From a7d494553e80fa9794698c29433d15bdae2213ed Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 12 Feb 2026 11:03:29 -0800 Subject: [PATCH 14/19] Fix testcases --- tests/unittests/sources/test_azure.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index d4123614ff1..122a5e52937 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -1738,7 +1738,7 @@ def test_password_given(self, get_ds, mocker): # this test isn't to verify the differences between crypt and passlib, # so hardcode passlib usage as crypt is deprecated. mocker.patch.object( - dsaz, "blowfish_hash", passlib.hash.sha512_crypt.hash + dsaz, "hash_password", passlib.hash.sha512_crypt.hash ) data = { "ovfcontent": construct_ovf_env( @@ -5649,7 +5649,7 @@ def test_dependency_fallback(self): """Ensure that crypt/passlib import failover gets exercised on all Python versions """ - assert dsaz.encrypt_pass("`") + assert dsaz.hash_password("`") class TestQueryVmId: @@ -5761,7 +5761,7 @@ def mock_import(name, *args, **kwargs): if name == "crypt": raise ImportError("No module named 'crypt'") if name == "passlib.hash": - raise ImportError("No module named 'passlib'") + raise ImportError("No module named 'passlib'", name="passlib") return real_import(name, *args, **kwargs) with mock.patch.object( From 9dc2cfe5fb688f886369e4df4215f0c68146de8a Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Tue, 17 Feb 2026 11:31:34 -0800 Subject: [PATCH 15/19] Expand test to handle when passlib is not installed --- tests/unittests/sources/test_azure.py | 67 ++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 122a5e52937..c5cf8857602 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -8,10 +8,14 @@ import logging import os import stat +import sys import xml.etree.ElementTree as ET from pathlib import Path -import passlib.hash +try: + import passlib.hash +except ImportError: + passlib = None # type: ignore import pytest import requests @@ -1733,6 +1737,9 @@ def test_username_used(self, get_ds): assert "ssh_pwauth" not in dsrc.cfg + @pytest.mark.skipif( + passlib is None, reason="passlib not installed" + ) def test_password_given(self, get_ds, mocker): # The crypt module has platform-specific behavior and the purpose of # this test isn't to verify the differences between crypt and passlib, @@ -5738,20 +5745,56 @@ def test_crypt_working(self): def test_crypt_not_installed_passlib_fallback(self): """Test that hash_password falls back to passlib when missing crypt.""" real_import = builtins.__import__ + passlib_available = True + try: + import passlib.hash as _passlib_hash + except ImportError: + passlib_available = False + + if passlib_available: + # passlib is installed; block crypt and let passlib work normally + def mock_import(name, *args, **kwargs): + if name == "crypt": + raise ImportError("No module named 'crypt'") + return real_import(name, *args, **kwargs) + + with mock.patch.object( + builtins, "__import__", side_effect=mock_import + ): + result = dsaz.hash_password("testpassword") - def mock_import(name, *args, **kwargs): - if name == "crypt": - raise ImportError("No module named 'crypt'") - return real_import(name, *args, **kwargs) + # Verify we got a valid SHA-512 hash from passlib + assert result.startswith("$6$") + assert _passlib_hash.sha512_crypt.verify( + "testpassword", result + ) + else: + # passlib is not installed; mock it to return a known hash + mock_passlib_hash = mock.MagicMock() + mock_passlib_hash.sha512_crypt.hash.return_value = ( + "$6$mocksalt$mockedhash" + ) - with mock.patch.object( - builtins, "__import__", side_effect=mock_import - ): - result = dsaz.hash_password("testpassword") + def mock_import(name, *args, **kwargs): + if name == "crypt": + raise ImportError("No module named 'crypt'") + if name == "passlib.hash": + mod = mock.MagicMock() + mod.hash = mock_passlib_hash + sys.modules["passlib"] = mod + sys.modules["passlib.hash"] = mock_passlib_hash + return mod + return real_import(name, *args, **kwargs) + + with mock.patch.object( + builtins, "__import__", side_effect=mock_import + ): + result = dsaz.hash_password("testpassword") - # Verify we got a valid SHA-512 hash from passlib - assert result.startswith("$6$") - assert passlib.hash.sha512_crypt.verify("testpassword", result) + assert result == "$6$mocksalt$mockedhash" + mock_passlib_hash.sha512_crypt.hash.assert_called_once_with( + "testpassword" + ) def test_crypt_and_passlib_unavailable_raises_error(self): """Test that hash_password raises ReportableErrorImportError.""" From c692759c665d6739a088a5088660d20f8ee5657c Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Tue, 17 Feb 2026 11:32:59 -0800 Subject: [PATCH 16/19] Formatting --- tests/unittests/sources/test_azure.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index c5cf8857602..3d9e85605b1 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -1737,9 +1737,7 @@ def test_username_used(self, get_ds): assert "ssh_pwauth" not in dsrc.cfg - @pytest.mark.skipif( - passlib is None, reason="passlib not installed" - ) + @pytest.mark.skipif(passlib is None, reason="passlib not installed") def test_password_given(self, get_ds, mocker): # The crypt module has platform-specific behavior and the purpose of # this test isn't to verify the differences between crypt and passlib, @@ -5765,9 +5763,7 @@ def mock_import(name, *args, **kwargs): # Verify we got a valid SHA-512 hash from passlib assert result.startswith("$6$") - assert _passlib_hash.sha512_crypt.verify( - "testpassword", result - ) + assert _passlib_hash.sha512_crypt.verify("testpassword", result) else: # passlib is not installed; mock it to return a known hash mock_passlib_hash = mock.MagicMock() From 8c34360e41f0d3d15e1113be2aa6ff511318af5d Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 26 Feb 2026 09:14:32 -0800 Subject: [PATCH 17/19] Move TestDependencyFallback into TestHashPassword --- tests/unittests/sources/test_azure.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 3d9e85605b1..f16c58f1e24 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -5648,15 +5648,6 @@ def test_missing_secondary( assert azure_ds.validate_imds_network_metadata(imds_md) is False - -class TestDependencyFallback: - def test_dependency_fallback(self): - """Ensure that crypt/passlib import failover gets exercised on all - Python versions - """ - assert dsaz.hash_password("`") - - class TestQueryVmId: @mock.patch.object( identity, "query_system_uuid", side_effect=["test-system-uuid"] @@ -5724,6 +5715,12 @@ def test_query_vm_id_vm_id_conversion_failure( class TestHashPassword: """Tests for the hash_password function.""" + def test_dependency_fallback(self): + """Ensure that crypt/passlib import failover gets exercised on all + Python versions + """ + assert dsaz.hash_password("`") + def test_crypt_working(self): """Test that hash_password uses crypt when available.""" mock_crypt = mock.MagicMock() From 71f9dfed1a9252283dec9e37cfb044b599ddb7db Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 26 Feb 2026 13:28:10 -0800 Subject: [PATCH 18/19] Fix linting --- tests/unittests/sources/test_azure.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index f16c58f1e24..19bffdc9fad 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -5648,6 +5648,7 @@ def test_missing_secondary( assert azure_ds.validate_imds_network_metadata(imds_md) is False + class TestQueryVmId: @mock.patch.object( identity, "query_system_uuid", side_effect=["test-system-uuid"] From a3c339d08c3d1536e3310972fb335d73d42d9898 Mon Sep 17 00:00:00 2001 From: Cade Jacobson Date: Thu, 26 Feb 2026 13:52:24 -0800 Subject: [PATCH 19/19] Ensure hashed passwords begin with a SHA-512 prefix --- tests/unittests/sources/test_azure.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unittests/sources/test_azure.py b/tests/unittests/sources/test_azure.py index 19bffdc9fad..3f552dab559 100644 --- a/tests/unittests/sources/test_azure.py +++ b/tests/unittests/sources/test_azure.py @@ -5720,7 +5720,9 @@ def test_dependency_fallback(self): """Ensure that crypt/passlib import failover gets exercised on all Python versions """ - assert dsaz.hash_password("`") + result = dsaz.hash_password("`") + assert result + assert result.startswith("$6$") def test_crypt_working(self): """Test that hash_password uses crypt when available."""