Repository: Azure/WALinuxAgent
Pull request #3070 (2024-02-27 20:19:26)

Use legacycrypt instead of crypt on Python >= 3.13
The crypt module will be removed in Python 3.13. Using legacycrypt as a temporary workaround.

narrieta: @nagworld9 

`question for my understanding, what is this requires does? will setup fail if these modules not present?`

from my experimentation, it just creates a requirements file for the Agent's package, not sure what tool does the actual install. I'll have the JIT/PA folks to follow that up, since it's those components who depend on this module. As far as the Extension Handler, the dummy 'crypt' function I define will prevent the Agent from crashing if the module is not installed.

Patch:
diff --git a/azurelinuxagent/common/osutil/default.py b/azurelinuxagent/common/osutil/default.py
index c52146ca..26114a28 100644
--- a/azurelinuxagent/common/osutil/default.py
+++ b/azurelinuxagent/common/osutil/default.py
@@ -16,6 +16,7 @@
 # Requires Python 2.6+ and Openssl 1.0+
 #
 
+import array
 import base64
 import datetime
 import errno
@@ -26,15 +27,26 @@ import multiprocessing
 import os
 import platform
 import pwd
+import random
 import re
 import shutil
 import socket
+import string
 import struct
 import sys
 import time
 from pwd import getpwall
 
-import array
+from azurelinuxagent.common.exception import OSUtilError
+# 'crypt' was removed in Python 3.13; use legacycrypt instead
+if sys.version_info[0] == 3 and sys.version_info[1] >= 13 or sys.version_info[0] > 3:
+    try:
+        from legacycrypt import crypt
+    except ImportError:
+        def crypt(password, salt):
+            raise OSUtilError("Please install the legacycrypt Python module to use this feature.")
+else:
+    from crypt import crypt  # pylint: disable=deprecated-module
 
 from azurelinuxagent.common import conf
 from azurelinuxagent.common import logger
@@ -42,7 +54,6 @@ from azurelinuxagent.common.utils import fileutil
 from azurelinuxagent.common.utils import shellutil
 from azurelinuxagent.common.utils import textutil
 
-from azurelinuxagent.common.exception import OSUtilError
 from azurelinuxagent.common.future import ustr, array_to_bytes
 from azurelinuxagent.common.utils.cryptutil import CryptUtil
 from azurelinuxagent.common.utils.flexible_version import FlexibleVersion
@@ -433,11 +444,21 @@ class DefaultOSUtil(object):
         if self.is_sys_user(username):
             raise OSUtilError(("User {0} is a system user, "
                                "will not set password.").format(username))
-        passwd_hash = textutil.gen_password_hash(password, crypt_id, salt_len)
+        passwd_hash = DefaultOSUtil.gen_password_hash(password, crypt_id, salt_len)
 
         self._run_command_raising_OSUtilError(["usermod", "-p", passwd_hash, username],
                                               err_msg="Failed to set password for {0}".format(username))
 
+    @staticmethod
+    def gen_password_hash(password, crypt_id, salt_len):
+        collection = string.ascii_letters + string.digits
+        salt = ''.join(random.choice(collection) for _ in range(salt_len))
+        salt = "${0}${1}".format(crypt_id, salt)
+        if sys.version_info[0] == 2:
+            # if python 2.*, encode to type 'str' to prevent Unicode Encode Error from crypt.crypt
+            password = password.encode('utf-8')
+        return crypt(password, salt)
+
     def get_users(self):
         return getpwall()
 
diff --git a/azurelinuxagent/common/osutil/freebsd.py b/azurelinuxagent/common/osutil/freebsd.py
index ea3a83e1..d2adc002 100644
--- a/azurelinuxagent/common/osutil/freebsd.py
+++ b/azurelinuxagent/common/osutil/freebsd.py
@@ -77,7 +77,7 @@ class FreeBSDOSUtil(DefaultOSUtil):
         if self.is_sys_user(username):
             raise OSUtilError(("User {0} is a system user, "
                                "will not set password.").format(username))
-        passwd_hash = textutil.gen_password_hash(password, crypt_id, salt_len)
+        passwd_hash = DefaultOSUtil.gen_password_hash(password, crypt_id, salt_len)
         self._run_command_raising_OSUtilError(['pw', 'usermod', username, '-H', '0'], cmd_input=passwd_hash,
                                               err_msg="Failed to set password for {0}".format(username))
 
diff --git a/azurelinuxagent/common/osutil/gaia.py b/azurelinuxagent/common/osutil/gaia.py
index 8a0f04b0..e11f482d 100644
--- a/azurelinuxagent/common/osutil/gaia.py
+++ b/azurelinuxagent/common/osutil/gaia.py
@@ -29,7 +29,6 @@ from azurelinuxagent.common.osutil.default import DefaultOSUtil
 from azurelinuxagent.common.utils.cryptutil import CryptUtil
 import azurelinuxagent.common.utils.fileutil as fileutil
 import azurelinuxagent.common.utils.shellutil as shellutil
-import azurelinuxagent.common.utils.textutil as textutil
 
 
 class GaiaOSUtil(DefaultOSUtil):
@@ -64,7 +63,7 @@ class GaiaOSUtil(DefaultOSUtil):
 
     def chpasswd(self, username, password, crypt_id=6, salt_len=10):
         logger.info('chpasswd')
-        passwd_hash = textutil.gen_password_hash(password, crypt_id, salt_len)
+        passwd_hash = DefaultOSUtil.gen_password_hash(password, crypt_id, salt_len)
         ret, out = self._run_clish(
             'set user admin password-hash ' + passwd_hash)
         if ret != 0:
diff --git a/azurelinuxagent/common/utils/textutil.py b/azurelinuxagent/common/utils/textutil.py
index 9e857274..4a0f9a75 100644
--- a/azurelinuxagent/common/utils/textutil.py
+++ b/azurelinuxagent/common/utils/textutil.py
@@ -17,12 +17,8 @@
 # Requires Python 2.6+ and Openssl 1.0+
 
 import base64
-# W4901: Deprecated module 'crypt' (deprecated-module)
-import crypt  # pylint: disable=deprecated-module
 import hashlib
-import random
 import re
-import string
 import struct
 import sys
 import traceback
@@ -288,16 +284,6 @@ def remove_bom(c):
     return c
 
 
-def gen_password_hash(password, crypt_id, salt_len):
-    collection = string.ascii_letters + string.digits
-    salt = ''.join(random.choice(collection) for _ in range(salt_len))
-    salt = "${0}${1}".format(crypt_id, salt)
-    if sys.version_info[0] == 2:
-        # if python 2.*, encode to type 'str' to prevent Unicode Encode Error from crypt.crypt
-        password = password.encode('utf-8')
-    return crypt.crypt(password, salt)
-
-
 def get_bytes_from_pem(pem_str):
     base64_bytes = ""
     for line in pem_str.split('\n'):
diff --git a/requirements.txt b/requirements.txt
index b0b7c874..ab6958a7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,2 +1,3 @@
 distro; python_version >= '3.8'
-pyasn1
\ No newline at end of file
+pyasn1
+legacycrypt; python_version >= '3.13'
diff --git a/setup.py b/setup.py
index 6b54d09e..2d51fae8 100755
--- a/setup.py
+++ b/setup.py
@@ -314,13 +314,16 @@ class install(_install):  # pylint: disable=C0103
 
 
 # Note to packagers and users from source.
-# In version 3.5 of Python distribution information handling in the platform
-# module was deprecated. Depending on the Linux distribution the
-# implementation may be broken prior to Python 3.7 wher the functionality
-# will be removed from Python 3
-requires = []  # pylint: disable=invalid-name
-if sys.version_info[0] >= 3 and sys.version_info[1] >= 7:
-    requires = ['distro']  # pylint: disable=invalid-name
+# * In version 3.5 of Python distribution information handling in the platform
+#   module was deprecated. Depending on the Linux distribution the
+#   implementation may be broken prior to Python 3.8 where the functionality
+#   will be removed from Python 3.
+# * In version 3.13 of Python, the crypt module was removed and legacycrypt is
+#   required instead.
+requires = [
+    "distro;python_version>='3.8'",
+    "legacycrypt;python_version>='3.13'",
+]
 
 modules = []  # pylint: disable=invalid-name
 


Test patch:
diff --git a/tests/common/osutil/test_default.py b/tests/common/osutil/test_default.py
index 68bd282d..79a802c7 100644
--- a/tests/common/osutil/test_default.py
+++ b/tests/common/osutil/test_default.py
@@ -1111,6 +1111,15 @@ class TestGetPublishedHostname(AgentTestCase):
         self.assertEqual(expected, actual, "get_hostname_record returned an incorrect hostname")
         self.assertEqual(expected, self.__get_published_hostname_contents(), "get_hostname_record returned an incorrect hostname")
 
+    def test_get_password_hash(self):
+        with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'test_passwords.txt'), 'rb') as in_file:
+            for data in in_file:
+                # Remove bom on bytes data before it is converted into string.
+                data = textutil.remove_bom(data)
+                data = ustr(data, encoding='utf-8')
+                password_hash = osutil.DefaultOSUtil.gen_password_hash(data, 6, 10)
+                self.assertNotEqual(None, password_hash)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/common/utils/test_passwords.txt b/tests/common/osutil/test_passwords.txt
similarity index 100%
rename from tests/common/utils/test_passwords.txt
rename to tests/common/osutil/test_passwords.txt
diff --git a/tests/common/utils/test_text_util.py b/tests/common/utils/test_text_util.py
index 8378ddfa..a9f8a9df 100644
--- a/tests/common/utils/test_text_util.py
+++ b/tests/common/utils/test_text_util.py
@@ -16,7 +16,6 @@
 #
 
 import hashlib
-import os
 import unittest
 from azurelinuxagent.common.future import LooseVersion as Version
 
@@ -26,15 +25,6 @@ from tests.lib.tools import AgentTestCase
 
 
 class TestTextUtil(AgentTestCase):
-    def test_get_password_hash(self):
-        with open(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'test_passwords.txt'), 'rb') as in_file:
-            for data in in_file:
-                # Remove bom on bytes data before it is converted into string.
-                data = textutil.remove_bom(data)
-                data = ustr(data, encoding='utf-8')
-                password_hash = textutil.gen_password_hash(data, 6, 10)
-                self.assertNotEqual(None, password_hash)
-
     def test_replace_non_ascii(self):
         data = ustr(b'\xef\xbb\xbfhehe', encoding='utf-8')
         self.assertEqual('hehe', textutil.replace_non_ascii(data))