Commit 0149bb9c11 for qemu.org

commit 0149bb9c11ec82801da335d7e401e701bf1d0f74
Author: Denis V. Lunev <den@openvz.org>
Date:   Tue Aug 11 12:28:36 2026 +0200

    iotests: cover the Parallels format extension parser

    Extend the existing test with hand crafted extension clusters, built
    in the test rather than shipped as samples. Each corruption case is
    one qemu-img info run which either opens the image or reports why it
    was rejected: extension magic and checksum, feature magic and flags,
    payloads running past the cluster, bitmap granularity, an L1 entry
    which overflows when converted to an offset, and a cluster_size larger
    than the image file. Two more cases load a bitmap spanning two L1
    entries.

    Termination by a signal is not covered by check=False, so it is caught
    and logged to keep the remaining cases running.

    Cc: Stefan Hajnoczi <stefanha@redhat.com>
    Cc: Thomas Huth <thuth@redhat.com>
    Signed-off-by: Denis V. Lunev <den@openvz.org>

diff --git a/tests/qemu-iotests/tests/parallels-read-bitmap b/tests/qemu-iotests/tests/parallels-read-bitmap
index 38ab5fa5b2..5cbef25018 100755
--- a/tests/qemu-iotests/tests/parallels-read-bitmap
+++ b/tests/qemu-iotests/tests/parallels-read-bitmap
@@ -18,14 +18,22 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 #

+import hashlib
+import struct
+import uuid
+
+from qemu.utils import VerboseProcessError
+
 import iotests
-from iotests import qemu_nbd_popen, qemu_img_map, log, file_path
+from iotests import qemu_nbd_popen, qemu_img, qemu_img_map, log, file_path

 iotests.script_initialize(supported_fmts=['parallels'])

 nbd_sock = file_path('nbd-sock', base_dir=iotests.sock_dir)
+nbd_sock2 = file_path('nbd-sock2', base_dir=iotests.sock_dir)
 disk = iotests.file_path('disk')
 bitmap = 'e4f2eed0-37fe-4539-b50b-85d2e7fd235f'
+MULTI_L1_UUID = '5f2e1c00-0000-4000-8000-0123456789ab'
 nbd_opts = f'driver=nbd,server.type=unix,server.path={nbd_sock}' \
         f',x-dirty-bitmap=qemu:dirty-bitmap:{bitmap}'

@@ -51,3 +59,173 @@ with qemu_nbd_popen('--read-only', f'--socket={nbd_sock}',
             log(f'{a}-{b-1}')
         else:
             log(a)
+
+
+# Hand crafted Format Extension clusters.
+
+EXT_MAGIC = 0xAB234CEF23DCEA87
+BITMAP_MAGIC = 0x20385FAE252CB34A
+
+CLUSTER = 512           # one sector per track
+SECTORS = 8             # 4 KiB disk
+EXT_SECTOR = 1          # the extension cluster follows the header
+EH_SIZE = 24            # ParallelsFormatExtensionHeader
+FH_SIZE = 24            # ParallelsFeatureHeader
+BF_SIZE = 32            # ParallelsDirtyBitmapFeature
+
+crafted = file_path('crafted')
+
+
+def feature(magic, data_size, flags=0):
+    return struct.pack('<QQII', magic, flags, data_size, 0)
+
+
+def bitmap_feature(l1, granularity=1, nb_sectors=SECTORS, bid=None):
+    """ParallelsDirtyBitmapFeature followed by its L1 table"""
+    bid = uuid.UUID(bid).bytes if bid else uuid.uuid4().bytes
+    return (struct.pack('<Q16sII', nb_sectors, bid, granularity, len(l1))
+            + b''.join(struct.pack('<Q', entry) for entry in l1))
+
+
+def extension(body, magic=EXT_MAGIC, checksum=True):
+    body = body.ljust(CLUSTER - EH_SIZE, b'\0')
+    csum = hashlib.md5(body).digest() if checksum else bytes(16)
+    return struct.pack('<Q16s', magic, csum) + body
+
+
+def parallels_header(tracks, bat_entries, nb_sectors, ext_sector):
+    return struct.pack('<16sIIIIIQIIIQ', b'WithouFreSpacExt', 2, 16, 0,
+                       tracks, bat_entries, nb_sectors, 0, 1, 0, ext_sector)
+
+
+def write_sparse_image(body, tracks, nb_sectors, bat_entries, ext_sector):
+    """Image whose extension cluster is the given body, zero padded."""
+    cluster_size = tracks * 512
+    md5 = hashlib.md5()
+    md5.update(body)
+    md5.update(bytes(cluster_size - EH_SIZE - len(body)))
+
+    with open(crafted, 'wb') as f:
+        f.write(parallels_header(tracks, bat_entries, nb_sectors, ext_sector))
+        f.write(bytes(ext_sector * 512 - 64))            # BAT, unallocated
+        f.write(struct.pack('<Q16s', EXT_MAGIC, md5.digest()) + body)
+        f.truncate(ext_sector * 512 + cluster_size)      # sparse zero tail
+
+
+def report(name):
+    log(f'--- {name}')
+    try:
+        res = qemu_img('info', '-f', iotests.imgfmt, crafted, check=False)
+    except VerboseProcessError as exc:
+        # check=False does not cover termination by a signal
+        log(f'qemu-img died with signal {-exc.returncode}')
+        return
+
+    log('image opened' if res.returncode == 0
+        else iotests.filter_testfiles(res.stdout).strip())
+
+
+def check(name, ext, tracks=1):
+    header = parallels_header(tracks, SECTORS, SECTORS, EXT_SECTOR)
+    with open(crafted, 'wb') as f:
+        f.write(header)
+        f.write(bytes(EXT_SECTOR * 512 - len(header)))   # BAT, unallocated
+        f.write(ext)
+
+    report(name)
+
+
+log('')
+
+# Control case, must keep working.
+good = bitmap_feature([0])
+check('well-formed extension',
+      extension(feature(BITMAP_MAGIC, len(good)) + good + feature(0, 0)))
+
+check('wrong extension magic',
+      extension(feature(0, 0), magic=EXT_MAGIC ^ 1))
+
+check('wrong extension checksum',
+      extension(feature(0, 0), checksum=False))
+
+check('unknown feature', extension(feature(BITMAP_MAGIC ^ 1, 0)))
+
+check('feature flags set', extension(feature(0, 0, flags=1)))
+
+check('feature data_size beyond the cluster',
+      extension(feature(BITMAP_MAGIC, CLUSTER)))
+
+payload = CLUSTER - EH_SIZE - FH_SIZE
+check('feature payload consumes the cluster',
+      extension(feature(BITMAP_MAGIC, payload)
+                + good.ljust(payload, b'\0')))
+
+# bf.size must match the disk size or the compiler elides the OOB loads.
+data1 = CLUSTER - 40 - EH_SIZE - FH_SIZE
+check('second feature payload beyond the cluster',
+      extension(feature(BITMAP_MAGIC, data1) + good.ljust(data1, b'\0')
+                + feature(BITMAP_MAGIC, BF_SIZE + 8)
+                + struct.pack('<Q', SECTORS)))
+
+# Zero, not a power of two, and wrapping when shifted: all three assert.
+for gran in (0, 3, 1 << 23):
+    bf = bitmap_feature([0], granularity=gran)
+    check(f'bitmap granularity {gran}',
+          extension(feature(BITMAP_MAGIC, len(bf)) + bf + feature(0, 0)))
+
+# entry << BDRV_SECTOR_BITS must not wrap into a negative offset.
+bf = bitmap_feature([0xffffffffffffffff])
+check('bitmap L1 entry overflows',
+      extension(feature(BITMAP_MAGIC, len(bf)) + bf + feature(0, 0)))
+
+# Largest cluster_size parallels_open() accepts, about 2 GiB.
+check('cluster_size beyond the file size',
+      extension(feature(0, 0)), tracks=0x7fffffff // 513)
+
+
+def check_big_cluster():
+    """Reaches the serialization coverage overflow, costs about 550 MiB."""
+    tracks = 1 << 19                    # cluster_size = 2^28
+    nb_sectors = 1 << 32                # 2 TiB, needs two L1 entries
+    ext_sector = 256                    # past the BAT
+    l1 = [0, 1]                         # entry 1 deserializes as all ones
+
+    bmap = bitmap_feature(l1, nb_sectors=nb_sectors)
+    body = feature(BITMAP_MAGIC, len(bmap)) + bmap + feature(0, 0)
+
+    write_sparse_image(body, tracks, nb_sectors, nb_sectors // tracks,
+                       ext_sector)
+    report('bitmap serialization coverage overflow')
+
+
+check_big_cluster()
+
+
+def check_multi_l1():
+    """Bitmap spanning two L1 entries with an ordinary 64 KiB cluster."""
+    tracks = 128                        # cluster_size = 64 KiB
+    nb_sectors = 1 << 27                # 64 GiB, needs two L1 entries
+    gran_sectors = 128                  # 64 KiB, as in the sample image
+    bat_entries = nb_sectors // tracks
+    ext_sector = (64 + 4 * bat_entries) // 512 + 8   # past the BAT
+    l1 = [0, 1]                         # first half clean, second all ones
+
+    bmap = bitmap_feature(l1, gran_sectors, nb_sectors, MULTI_L1_UUID)
+    body = feature(BITMAP_MAGIC, len(bmap)) + bmap + feature(0, 0)
+
+    write_sparse_image(body, tracks, nb_sectors, bat_entries, ext_sector)
+
+    log('--- bitmap spanning two L1 entries')
+    opts = f'driver=nbd,server.type=unix,server.path={nbd_sock2}' \
+           f',x-dirty-bitmap=qemu:dirty-bitmap:{MULTI_L1_UUID}'
+    with qemu_nbd_popen('--read-only', f'--socket={nbd_sock2}',
+                        f'--bitmap={MULTI_L1_UUID}', '-f', iotests.imgfmt,
+                        crafted):
+        for chunk in qemu_img_map('--image-opts', opts):
+            if not chunk['data']:
+                first = chunk['start'] >> 30
+                last = (chunk['start'] + chunk['length']) >> 30
+                log(f'dirty {first}-{last} GiB')
+
+
+check_multi_l1()
diff --git a/tests/qemu-iotests/tests/parallels-read-bitmap.out b/tests/qemu-iotests/tests/parallels-read-bitmap.out
index e8f6bc9e96..3b3f90c8de 100644
--- a/tests/qemu-iotests/tests/parallels-read-bitmap.out
+++ b/tests/qemu-iotests/tests/parallels-read-bitmap.out
@@ -4,3 +4,36 @@ dirty clusters (cluster size is 64K):
 10-12
 30
 Kill NBD server
+
+--- well-formed extension
+image opened
+--- wrong extension magic
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Wrong parallels Format Extension magic: 0xab234cef23dcea86, expected: 0xab234cef23dcea87
+--- wrong extension checksum
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Wrong checksum in Format Extension header. Format extension is corrupted.
+--- unknown feature
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Unknown feature: 0x20385fae252cb34b
+--- feature flags set
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Flags for extension feature are unsupported
+--- feature data_size beyond the cluster
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Feature data_size exceedes Format Extension cluster
+--- feature payload consumes the cluster
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Can not read feature header, as remaining bytes (0) in Format Extension is less than Feature header size (24)
+--- second feature payload beyond the cluster
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Feature data_size exceedes Format Extension cluster
+--- bitmap granularity 0
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Invalid bitmap granularity 0, expected a power of two of at least 512 bytes
+--- bitmap granularity 3
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Invalid bitmap granularity 1536, expected a power of two of at least 512 bytes
+--- bitmap granularity 8388608
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Invalid bitmap granularity 4294967296, expected a power of two of at least 512 bytes
+--- bitmap L1 entry overflows
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Failed to read bitmap data cluster: Input/output error
+--- cluster_size beyond the file size
+qemu-img: Could not open 'TEST_DIR/PID-crafted': Invalid image: Format Extension is outside the image file
+--- bitmap serialization coverage overflow
+image opened
+--- bitmap spanning two L1 entries
+Start NBD server
+dirty 32-64 GiB
+Kill NBD server