test_fs: Add exfat tests

Add tests for the exfat filesystem. These tests are largely an
extension of the FS_GENERIC tests with the following notable
exceptions.

The filesystem image for exfat tests is generated using combination
of exfatprogs mkfs.exfat and python fattools. The fattols are capable
of generating exfat filesystem images too, but this is not used, the
fattools are only used as a replacement for dosfstools 'mcopy' and
'mdir', which are used to insert files and directories into existing
fatfs images and list existing fatfs images respectively, without the
need for superuser access to mount such images.

The exfat filesystem has no filesystem specific command, there is only
the generic filesystem command interface, therefore check_ubconfig()
has to special case exfat and skip check for CONFIG_CMD_EXFAT and
instead check for CONFIG_FS_EXFAT.

Signed-off-by: Marek Vasut <marex@denx.de>
diff --git a/test/py/requirements.txt b/test/py/requirements.txt
index acfe17d..804a427 100644
--- a/test/py/requirements.txt
+++ b/test/py/requirements.txt
@@ -2,3 +2,4 @@
 pycryptodomex==3.21.0
 pytest==6.2.5
 pytest-xdist==2.5.0
+FATtools==1.0.42
diff --git a/test/py/tests/fs_helper.py b/test/py/tests/fs_helper.py
index 378d5ae..94a5b94 100644
--- a/test/py/tests/fs_helper.py
+++ b/test/py/tests/fs_helper.py
@@ -35,7 +35,9 @@
     else:
         mkfs_opt = ''
 
-    if re.match('fat', fs_type) or fs_type == 'fs_generic':
+    if fs_type == 'exfat':
+        fs_lnxtype = 'exfat'
+    elif re.match('fat', fs_type) or fs_type == 'fs_generic':
         fs_lnxtype = 'vfat'
     else:
         fs_lnxtype = fs_type
@@ -43,7 +45,7 @@
     if src_dir:
         if fs_lnxtype == 'ext4':
             mkfs_opt = mkfs_opt + ' -d ' + src_dir
-        elif fs_lnxtype != 'vfat':
+        elif fs_lnxtype != 'vfat' and fs_lnxtype != 'exfat':
             raise ValueError(f'src_dir not implemented for fs {fs_lnxtype}')
 
     count = (size + size_gran - 1) // size_gran
@@ -64,6 +66,8 @@
                 check_call(f'tune2fs -O ^metadata_csum {fs_img}', shell=True)
         elif fs_lnxtype == 'vfat' and src_dir:
             check_call(f'mcopy -i {fs_img} -vsmpQ {src_dir}/* ::/', shell=True)
+        elif fs_lnxtype == 'exfat' and src_dir:
+            check_call(f'fattools cp {src_dir}/* {fs_img}', shell=True)
         return fs_img
     except CalledProcessError:
         call(f'rm -f {fs_img}', shell=True)
diff --git a/test/py/tests/test_fs/conftest.py b/test/py/tests/test_fs/conftest.py
index 691bdf4..c73fb4a 100644
--- a/test/py/tests/test_fs/conftest.py
+++ b/test/py/tests/test_fs/conftest.py
@@ -11,11 +11,11 @@
 # pylint: disable=E0611
 from tests import fs_helper
 
-supported_fs_basic = ['fat16', 'fat32', 'ext4', 'fs_generic']
-supported_fs_ext = ['fat12', 'fat16', 'fat32', 'fs_generic']
+supported_fs_basic = ['fat16', 'fat32', 'exfat', 'ext4', 'fs_generic']
+supported_fs_ext = ['fat12', 'fat16', 'fat32', 'exfat', 'fs_generic']
 supported_fs_fat = ['fat12', 'fat16']
-supported_fs_mkdir = ['fat12', 'fat16', 'fat32', 'fs_generic']
-supported_fs_unlink = ['fat12', 'fat16', 'fat32', 'fs_generic']
+supported_fs_mkdir = ['fat12', 'fat16', 'fat32', 'exfat', 'fs_generic']
+supported_fs_unlink = ['fat12', 'fat16', 'fat32', 'exfat', 'fs_generic']
 supported_fs_symlink = ['ext4']
 supported_fs_rename = ['fat12', 'fat16', 'fat32']
 
@@ -117,7 +117,7 @@
     Return:
         A corresponding command prefix for file system type.
     """
-    if fs_type == 'fs_generic':
+    if fs_type == 'fs_generic' or fs_type == 'exfat':
         return ''
     elif re.match('fat', fs_type):
         return 'fat'
@@ -155,9 +155,11 @@
     Return:
         Nothing.
     """
-    if not config.buildconfig.get('config_cmd_%s' % fs_type, None):
+    if fs_type == 'exfat' and not config.buildconfig.get('config_fs_%s' % fs_type, None):
+        pytest.skip('.config feature "FS_%s" not enabled' % fs_type.upper())
+    if fs_type != 'exfat' and not config.buildconfig.get('config_cmd_%s' % fs_type, None):
         pytest.skip('.config feature "CMD_%s" not enabled' % fs_type.upper())
-    if fs_type == 'fs_generic':
+    if fs_type == 'fs_generic' or fs_type == 'exfat':
         return
     if not config.buildconfig.get('config_%s_write' % fs_type, None):
         pytest.skip('.config feature "%s_WRITE" not enabled'
@@ -197,7 +199,7 @@
     """
     fs_type = request.param
     fs_cmd_prefix = fstype_to_prefix(fs_type)
-    fs_cmd_write = 'save' if fs_type == 'fs_generic' else 'write'
+    fs_cmd_write = 'save' if fs_type == 'fs_generic' or fs_type == 'exfat' else 'write'
     fs_img = ''
 
     fs_ubtype = fstype_to_ubname(fs_type)
@@ -309,7 +311,7 @@
     """
     fs_type = request.param
     fs_cmd_prefix = fstype_to_prefix(fs_type)
-    fs_cmd_write = 'save' if fs_type == 'fs_generic' else 'write'
+    fs_cmd_write = 'save' if fs_type == 'fs_generic' or fs_type == 'exfat' else 'write'
     fs_img = ''
 
     fs_ubtype = fstype_to_ubname(fs_type)
diff --git a/test/py/tests/test_fs/fstest_helpers.py b/test/py/tests/test_fs/fstest_helpers.py
index c1447b4..d25326e 100644
--- a/test/py/tests/test_fs/fstest_helpers.py
+++ b/test/py/tests/test_fs/fstest_helpers.py
@@ -9,6 +9,8 @@
     try:
         if fs_type == 'ext4':
             check_call('fsck.ext4 -n -f %s' % fs_img, shell=True)
+        elif fs_type == 'exfat':
+            check_call('fsck.exfat -n %s' % fs_img, shell=True)
         elif fs_type in ['fat12', 'fat16', 'fat32']:
             check_call('fsck.fat -n %s' % fs_img, shell=True)
     except CalledProcessError:
diff --git a/test/py/tests/test_fs/test_ext.py b/test/py/tests/test_fs/test_ext.py
index a75cd74..41f126e 100644
--- a/test/py/tests/test_fs/test_ext.py
+++ b/test/py/tests/test_fs/test_ext.py
@@ -345,11 +345,19 @@
                 '%s%s host 0:0 %x /%s 0'
                     % (fs_cmd_prefix, fs_cmd_write, ADDR, MANGLE_FILE)])
             assert('0 bytes written' in ''.join(output))
-            # Test Case 12b - Read file system content
-            output = check_output('mdir -i %s' % fs_img, shell=True).decode()
-            # Test Case 12c - Check if short filename is not mangled
-            assert(str2fat(PLAIN_FILE) in ''.join(output))
-            # Test Case 12d - Check if long filename is mangled
-            assert(str2fat(MANGLE_FILE) in ''.join(output))
+            if fs_type == 'exfat':
+                # Test Case 12b - Read file system content
+                output = check_output('fattools ls %s' % fs_img, shell=True).decode()
+                # Test Case 12c - Check if short filename is not mangled
+                assert(PLAIN_FILE in ''.join(output))
+                # Test Case 12d - Check if long filename is mangled
+                assert(MANGLE_FILE in ''.join(output))
+            else:
+                # Test Case 12b - Read file system content
+                output = check_output('mdir -i %s' % fs_img, shell=True).decode()
+                # Test Case 12c - Check if short filename is not mangled
+                assert(str2fat(PLAIN_FILE) in ''.join(output))
+                # Test Case 12d - Check if long filename is mangled
+                assert(str2fat(MANGLE_FILE) in ''.join(output))
 
             assert_fs_integrity(fs_type, fs_img)
diff --git a/tools/docker/Dockerfile b/tools/docker/Dockerfile
index 5699123..80e9247 100644
--- a/tools/docker/Dockerfile
+++ b/tools/docker/Dockerfile
@@ -74,6 +74,7 @@
 	e2fsprogs \
 	efitools \
 	erofs-utils \
+	exfatprogs \
 	expect \
 	fakeroot \
 	flex \