Skip to content

Commit e8f70ae

Browse files
committed
Merge remote-tracking branch 'origin/main' into probertson-extract-ssh-key-cert
2 parents 4333b98 + 0052a66 commit e8f70ae

File tree

8 files changed

+199
-48
lines changed

8 files changed

+199
-48
lines changed

cloudinit/sources/DataSourceAzure.py

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
# This file is part of cloud-init. See LICENSE file for license information.
66

77
import base64
8-
import functools
98
import logging
109
import os
1110
import os.path
@@ -50,31 +49,6 @@
5049
)
5150
from cloudinit.url_helper import UrlError
5251

53-
try:
54-
with warnings.catch_warnings():
55-
warnings.simplefilter("ignore", category=DeprecationWarning)
56-
import crypt # pylint: disable=W4901
57-
58-
blowfish_hash: Any = functools.partial(
59-
crypt.crypt, salt=f"$6${util.rand_str(strlen=16)}"
60-
)
61-
except (ImportError, AttributeError):
62-
try:
63-
import passlib.hash
64-
65-
blowfish_hash = passlib.hash.sha512_crypt.hash
66-
except ImportError:
67-
68-
def blowfish_hash(_):
69-
"""Raise when called so that importing this module doesn't throw
70-
ImportError when ds_detect() returns false. In this case, crypt
71-
and passlib are not needed.
72-
"""
73-
raise ImportError(
74-
"crypt and passlib not found, missing dependency"
75-
)
76-
77-
7852
LOG = logging.getLogger(__name__)
7953

8054
DS_NAME = "Azure"
@@ -166,6 +140,35 @@ def find_dev_from_busdev(camcontrol_out: str, busdev: str) -> Optional[str]:
166140
return None
167141

168142

143+
def hash_password(password: str) -> str:
144+
"""Hash a password using SHA-512 crypt.
145+
146+
Try to use crypt, falling back to passlib.
147+
148+
If neither are available, raise ReportableErrorImportError.
149+
150+
:param password: plaintext password to hash.
151+
:return: The hashed password string.
152+
:raises ReportableErrorImportError: If crypt and passlib are unavailable.
153+
"""
154+
try:
155+
with warnings.catch_warnings():
156+
warnings.simplefilter("ignore", category=DeprecationWarning)
157+
import crypt # pylint: disable=W4901
158+
159+
salt = crypt.mksalt(crypt.METHOD_SHA512)
160+
return crypt.crypt(password, salt)
161+
except (ImportError, AttributeError):
162+
pass
163+
164+
try:
165+
import passlib.hash
166+
167+
return passlib.hash.sha512_crypt.hash(password)
168+
except ImportError as error:
169+
raise errors.ReportableErrorImportError(error=error) from error
170+
171+
169172
def normalize_mac_address(mac: str) -> str:
170173
"""Normalize mac address with colons and lower-case."""
171174
if len(mac) == 12:
@@ -1982,7 +1985,7 @@ def read_azure_ovf(contents):
19821985
if ovf_env.password:
19831986
defuser["lock_passwd"] = False
19841987
if DEF_PASSWD_REDACTION != ovf_env.password:
1985-
defuser["hashed_passwd"] = encrypt_pass(ovf_env.password)
1988+
defuser["hashed_passwd"] = hash_password(ovf_env.password)
19861989

19871990
if defuser:
19881991
cfg["system_info"] = {"default_user": defuser}
@@ -2007,10 +2010,6 @@ def read_azure_ovf(contents):
20072010
return (md, ud, cfg)
20082011

20092012

2010-
def encrypt_pass(password):
2011-
return blowfish_hash(password)
2012-
2013-
20142013
def find_primary_nic():
20152014
candidate_nics = net.find_candidate_nics()
20162015
if candidate_nics:

cloudinit/sources/DataSourceWSL.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,21 @@ def find_home() -> PurePath:
9090
raises: IOError when no mountpoint with cmd.exe is found
9191
ProcessExecutionError when either cmd.exe is unable to retrieve
9292
the user's home directory
93+
UnicodeDecodeError when cmd.exe /U outputs invalid UTF16LE
9394
"""
9495
cmd = cmd_executable()
9596

9697
# cloud-init runs too early to rely on binfmt to execute Windows binaries.
9798
# But we know that `/init` is the interpreter, so we can run it directly.
9899
# See /proc/sys/fs/binfmt_misc/WSLInterop[-late]
99100
# inside any WSL instance for more details.
100-
home, _ = subp.subp(["/init", cmd.as_posix(), "/C", "echo %USERPROFILE%"])
101-
home = home.rstrip()
101+
# Invoking with "/U" makes it output UTF-16LE, which is more predictable
102+
# than ANSI Code Pages for anything above the ASCII range.
103+
home, _ = subp.subp(
104+
["/init", cmd.as_posix(), "/U", "/C", "echo.%USERPROFILE%"],
105+
decode=False,
106+
)
107+
home = home.decode("utf-16-le").rstrip()
102108
if not home:
103109
raise subp.ProcessExecutionError(
104110
"No output from cmd.exe to show the user profile dir."
@@ -443,7 +449,7 @@ def _get_data(self) -> bool:
443449

444450
try:
445451
user_home = find_home()
446-
except IOError as e:
452+
except (IOError, ValueError) as e:
447453
LOG.debug("Unable to detect WSL datasource: %s", e)
448454
return False
449455

cloudinit/sources/azure/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ def __init__(self, *, exception: ValueError) -> None:
168168
self.supporting_data["exception"] = repr(exception)
169169

170170

171+
class ReportableErrorImportError(ReportableError):
172+
def __init__(self, *, error: ImportError) -> None:
173+
super().__init__(f"error importing {error.name} library")
174+
175+
self.supporting_data["error"] = repr(error)
176+
177+
171178
class ReportableErrorOsDiskPpsFailure(ReportableError):
172179
def __init__(self) -> None:
173180
super().__init__("error waiting for host shutdown")

cloudinit/temp_utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ def get_tmp_ancestor(odir=None, needs_exe: bool = False):
2020
if needs_exe:
2121
return _EXE_ROOT_TMPDIR
2222
if os.getuid() == 0:
23-
return _ROOT_TMPDIR
23+
if util.is_BSD():
24+
return "/var/" + _ROOT_TMPDIR
25+
else:
26+
return _ROOT_TMPDIR
2427
return os.environ.get("TMPDIR", "/tmp")
2528

2629

tests/unittests/sources/azure/test_errors.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,15 @@ def test_imds_metadata_parsing_exception():
211211
assert error.supporting_data["exception"] == repr(exception)
212212

213213

214+
def test_import_error():
215+
exception = ImportError("No module named 'foobar'", name="foobar")
216+
217+
error = errors.ReportableErrorImportError(error=exception)
218+
219+
assert error.reason == "error importing foobar library"
220+
assert error.supporting_data["error"] == repr(exception)
221+
222+
214223
def test_ovf_parsing_exception():
215224
error = None
216225
try:

tests/unittests/sources/test_azure.py

Lines changed: 120 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
# This file is part of cloud-init. See LICENSE file for license information.
22
# pylint: disable=attribute-defined-outside-init
33

4+
import builtins
45
import copy
56
import datetime
67
import json
78
import logging
89
import os
910
import stat
11+
import sys
1012
import xml.etree.ElementTree as ET
1113
from pathlib import Path
1214

13-
import passlib.hash
15+
try:
16+
import passlib.hash
17+
except ImportError:
18+
passlib = None # type: ignore
1419
import pytest
1520
import requests
1621

@@ -1732,12 +1737,13 @@ def test_username_used(self, get_ds):
17321737

17331738
assert "ssh_pwauth" not in dsrc.cfg
17341739

1740+
@pytest.mark.skipif(passlib is None, reason="passlib not installed")
17351741
def test_password_given(self, get_ds, mocker):
17361742
# The crypt module has platform-specific behavior and the purpose of
17371743
# this test isn't to verify the differences between crypt and passlib,
17381744
# so hardcode passlib usage as crypt is deprecated.
17391745
mocker.patch.object(
1740-
dsaz, "blowfish_hash", passlib.hash.sha512_crypt.hash
1746+
dsaz, "hash_password", passlib.hash.sha512_crypt.hash
17411747
)
17421748
data = {
17431749
"ovfcontent": construct_ovf_env(
@@ -2434,6 +2440,19 @@ def test_wb_invalid_ovf_env_xml_calls_read_azure_ovf(self, tmp_path):
24342440
== cm.value.reason
24352441
)
24362442

2443+
def test_import_error_from_failed_import(self):
2444+
"""Attempt to import a module that is not present"""
2445+
try:
2446+
import nonexistent_module_that_will_never_exist # type: ignore[import-not-found] # noqa: F401 # isort:skip
2447+
except ImportError as error:
2448+
reportable_error = errors.ReportableErrorImportError(error=error)
2449+
2450+
assert (
2451+
reportable_error.reason == "error importing "
2452+
"nonexistent_module_that_will_never_exist library"
2453+
)
2454+
assert reportable_error.supporting_data["error"] == repr(error)
2455+
24372456

24382457
class TestReadAzureOvf:
24392458
def test_invalid_xml_raises_non_azure_ds(self):
@@ -5837,14 +5856,6 @@ def test_missing_secondary(
58375856
assert azure_ds.validate_imds_network_metadata(imds_md) is False
58385857

58395858

5840-
class TestDependencyFallback:
5841-
def test_dependency_fallback(self):
5842-
"""Ensure that crypt/passlib import failover gets exercised on all
5843-
Python versions
5844-
"""
5845-
assert dsaz.encrypt_pass("`")
5846-
5847-
58485859
class TestQueryVmId:
58495860
@mock.patch.object(
58505861
identity, "query_system_uuid", side_effect=["test-system-uuid"]
@@ -5907,3 +5918,102 @@ def test_query_vm_id_vm_id_conversion_failure(
59075918

59085919
mock_query_system_uuid.assert_called_once()
59095920
mock_convert_uuid.assert_called_once_with("test-system-uuid")
5921+
5922+
5923+
class TestHashPassword:
5924+
"""Tests for the hash_password function."""
5925+
5926+
def test_dependency_fallback(self):
5927+
"""Ensure that crypt/passlib import failover gets exercised on all
5928+
Python versions
5929+
"""
5930+
result = dsaz.hash_password("`")
5931+
assert result
5932+
assert result.startswith("$6$")
5933+
5934+
def test_crypt_working(self):
5935+
"""Test that hash_password uses crypt when available."""
5936+
mock_crypt = mock.MagicMock()
5937+
mock_crypt.METHOD_SHA512 = "sha512"
5938+
mock_crypt.mksalt.return_value = "$6$saltvalue"
5939+
mock_crypt.crypt.return_value = "$6$saltvalue$hashedpassword"
5940+
5941+
with mock.patch.dict("sys.modules", {"crypt": mock_crypt}):
5942+
result = dsaz.hash_password("testpassword")
5943+
5944+
mock_crypt.mksalt.assert_called_once_with("sha512")
5945+
mock_crypt.crypt.assert_called_once_with(
5946+
"testpassword", "$6$saltvalue"
5947+
)
5948+
assert result == "$6$saltvalue$hashedpassword"
5949+
5950+
def test_crypt_not_installed_passlib_fallback(self):
5951+
"""Test that hash_password falls back to passlib when missing crypt."""
5952+
real_import = builtins.__import__
5953+
passlib_available = True
5954+
try:
5955+
import passlib.hash as _passlib_hash
5956+
except ImportError:
5957+
passlib_available = False
5958+
5959+
if passlib_available:
5960+
# passlib is installed; block crypt and let passlib work normally
5961+
def mock_import(name, *args, **kwargs):
5962+
if name == "crypt":
5963+
raise ImportError("No module named 'crypt'")
5964+
return real_import(name, *args, **kwargs)
5965+
5966+
with mock.patch.object(
5967+
builtins, "__import__", side_effect=mock_import
5968+
):
5969+
result = dsaz.hash_password("testpassword")
5970+
5971+
# Verify we got a valid SHA-512 hash from passlib
5972+
assert result.startswith("$6$")
5973+
assert _passlib_hash.sha512_crypt.verify("testpassword", result)
5974+
else:
5975+
# passlib is not installed; mock it to return a known hash
5976+
mock_passlib_hash = mock.MagicMock()
5977+
mock_passlib_hash.sha512_crypt.hash.return_value = (
5978+
"$6$mocksalt$mockedhash"
5979+
)
5980+
5981+
def mock_import(name, *args, **kwargs):
5982+
if name == "crypt":
5983+
raise ImportError("No module named 'crypt'")
5984+
if name == "passlib.hash":
5985+
mod = mock.MagicMock()
5986+
mod.hash = mock_passlib_hash
5987+
sys.modules["passlib"] = mod
5988+
sys.modules["passlib.hash"] = mock_passlib_hash
5989+
return mod
5990+
return real_import(name, *args, **kwargs)
5991+
5992+
with mock.patch.object(
5993+
builtins, "__import__", side_effect=mock_import
5994+
):
5995+
result = dsaz.hash_password("testpassword")
5996+
5997+
assert result == "$6$mocksalt$mockedhash"
5998+
mock_passlib_hash.sha512_crypt.hash.assert_called_once_with(
5999+
"testpassword"
6000+
)
6001+
6002+
def test_crypt_and_passlib_unavailable_raises_error(self):
6003+
"""Test that hash_password raises ReportableErrorImportError."""
6004+
real_import = builtins.__import__
6005+
6006+
def mock_import(name, *args, **kwargs):
6007+
if name == "crypt":
6008+
raise ImportError("No module named 'crypt'")
6009+
if name == "passlib.hash":
6010+
raise ImportError("No module named 'passlib'", name="passlib")
6011+
return real_import(name, *args, **kwargs)
6012+
6013+
with mock.patch.object(
6014+
builtins, "__import__", side_effect=mock_import
6015+
):
6016+
with pytest.raises(errors.ReportableErrorImportError) as exc_info:
6017+
dsaz.hash_password("testpassword")
6018+
6019+
assert "passlib" in exc_info.value.reason

tests/unittests/sources/test_wsl.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,19 @@ def test_cmd_exe_no_win_mounts(self, m_mounts, m_os_access):
144144
with pytest.raises(IOError):
145145
wsl.cmd_executable()
146146

147+
@mock.patch("cloudinit.sources.DataSourceWSL.cmd_executable")
148+
@mock.patch("cloudinit.util.subp.subp")
149+
def test_find_home_raises(self, m_subp, m_cmd):
150+
# The value really doesn't matter.
151+
m_cmd.return_value = PurePath("/mnt/c/cmd.exe")
152+
m_subp.return_value = util.subp.SubpResult(
153+
"I am UTF-8 🦄 !".encode("utf-8"), "\r\n".encode("utf-8")
154+
)
155+
# Checking for ValueError instead of UnicodeDecodeError because
156+
# that's what we catch at the call sites.
157+
with pytest.raises(ValueError):
158+
wsl.find_home()
159+
147160
@pytest.mark.parametrize(
148161
"linux_distro_value,files",
149162
(

tools/ds-identify

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1708,7 +1708,9 @@ WSL_path() {
17081708
WSL_run_cmd() {
17091709
local val="" exepath="$1"
17101710
shift
1711-
_RET=$(/init "$exepath" /c "$@" 2>/dev/null)
1711+
# Using the '/u' flag to enforce Unicode (UTF-16 LE), thus we need to decode it afterwards.
1712+
# It's more reliable than the default ANSI Code Pages for anything above the ASCII range.
1713+
_RET=$(/init "$exepath" /u /c "$@" 2>/dev/null | iconv --from-code UTF-16LE --to-code UTF-8)
17121714
}
17131715

17141716
WSL_profile_dir() {
@@ -1719,10 +1721,12 @@ WSL_profile_dir() {
17191721
for m in $@; do
17201722
cmdexe="$m/Windows/System32/cmd.exe"
17211723
if command -v "$cmdexe" > /dev/null 2>&1; then
1722-
# Here WSL's proprietary `/init` is used to start the Windows cmd.exe
1724+
# Here WSL's `/init` is used to start the Windows cmd.exe
17231725
# to output the Windows user profile directory path, which is
17241726
# held by the environment variable %USERPROFILE%.
1725-
WSL_run_cmd "$cmdexe" "echo %USERPROFILE%"
1727+
# See https://wsl.dev/technical-documentation/interop/ for more information on how /init
1728+
# is used to launch Windows binaries.
1729+
WSL_run_cmd "$cmdexe" "echo.%USERPROFILE%"
17261730
profiledir="${_RET%%[[:cntrl:]]}"
17271731
if [ -n "$profiledir" ]; then
17281732
# wslpath is a program supplied by WSL itself that translates Windows and Linux paths,

0 commit comments

Comments
 (0)