[PATCH] block: Add overlaybd read-only format driver

Huiba Li posted 1 patch 5 days, 12 hours ago
MAINTAINERS                            |    7 +
block/meson.build                      |    5 +-
block/overlaybd.c                      | 1962 ++++++++++++++++++++++++
docs/system/qemu-block-drivers.rst.inc |   37 +
meson.build                            |    9 +
meson_options.txt                      |    4 +
qapi/block-core.json                   |   39 +
scripts/meson-buildoptions.sh          |    6 +
tests/qemu-iotests/tests/overlaybd     |  795 ++++++++++
tests/qemu-iotests/tests/overlaybd.out |   10 +
10 files changed, 2873 insertions(+), 1 deletion(-)
create mode 100644 block/overlaybd.c
create mode 100755 tests/qemu-iotests/tests/overlaybd
create mode 100644 tests/qemu-iotests/tests/overlaybd.out
[PATCH] block: Add overlaybd read-only format driver
Posted by Huiba Li 5 days, 12 hours ago
Overlaybd (overlay block device) is a novel layered
image format based on block-diff representation. It
was originally designed for fast launching of containers,
and it is also feasible for virtual machines.

It features:
(1) small index with variable-length block size;
(2) fast lookup with linearized B+ tree and SIMD; (binary search for now);
(3) O(1) lookup at any number of depth (internal backing chain);
(4) optionally fast decompression with lz4 or zstd;
(5) OCI-compatible layering and manifest.

Signed-off-by: Huiba Li <huiba.lhb@alibaba-inc.com>
---
 MAINTAINERS                            |    7 +
 block/meson.build                      |    5 +-
 block/overlaybd.c                      | 1962 ++++++++++++++++++++++++
 docs/system/qemu-block-drivers.rst.inc |   37 +
 meson.build                            |    9 +
 meson_options.txt                      |    4 +
 qapi/block-core.json                   |   39 +
 scripts/meson-buildoptions.sh          |    6 +
 tests/qemu-iotests/tests/overlaybd     |  795 ++++++++++
 tests/qemu-iotests/tests/overlaybd.out |   10 +
 10 files changed, 2873 insertions(+), 1 deletion(-)
 create mode 100644 block/overlaybd.c
 create mode 100755 tests/qemu-iotests/tests/overlaybd
 create mode 100644 tests/qemu-iotests/tests/overlaybd.out

diff --git a/MAINTAINERS b/MAINTAINERS
index 6be9c5725b..a71264490b 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -4436,6 +4436,13 @@ L: qemu-block@nongnu.org
 S: Supported
 F: block/dmg.c
 
+overlaybd
+M: Huiba Li <huiba.lhb@alibaba-inc.com>
+L: qemu-block@nongnu.org
+S: Supported
+F: block/overlaybd.c
+F: tests/qemu-iotests/tests/overlaybd*
+
 parallels
 M: Stefan Hajnoczi <stefanha@redhat.com>
 M: Denis V. Lunev <den@openvz.org>
diff --git a/block/meson.build b/block/meson.build
index bc419aebf0..48fdac8b0b 100644
--- a/block/meson.build
+++ b/block/meson.build
@@ -39,7 +39,7 @@ block_ss.add(files(
   'throttle.c',
   'throttle-groups.c',
   'write-threshold.c',
-), zstd, zlib)
+), zstd, zlib, lz4)
 
 system_ss.add(when: 'CONFIG_TCG', if_true: files('blkreplay.c'))
 system_ss.add(files('block-ram-registrar.c'))
@@ -69,6 +69,9 @@ endif
 if get_option('bochs').allowed()
   block_ss.add(files('bochs.c'))
 endif
+if get_option('overlaybd').allowed()
+  block_ss.add(files('overlaybd.c'))
+endif
 if get_option('vvfat').allowed()
   block_ss.add(files('vvfat.c'))
 endif
diff --git a/block/overlaybd.c b/block/overlaybd.c
new file mode 100644
index 0000000000..9d3b5eb437
--- /dev/null
+++ b/block/overlaybd.c
@@ -0,0 +1,1962 @@
+/*
+ *
+ * Read-only block driver for OverlayBD images
+ *
+ * An OverlayBD image is a stack of layer files. Each layer is an LSMT
+ * blob: a 4096-byte header, a data region,
+ * a sorted index of 16-byte LBA mappings, and a 4096-byte trailer. The
+ * driver merges the per-layer indexes top-down (a top layer's mapping
+ * wins its whole extent; lower layers only fill holes) and serves reads
+ * at 512-byte sector granularity. Holes and zeroed mappings read as
+ * zeros.
+ *
+ * Layers may optionally be wrapped in a single-entry tar and/or
+ * compressed with ZFile (block-wise LZ4 or ZSTD with a jump table).
+ *
+ * The layer stack is given either by an OCI image manifest, whose layer
+ * digests name blobs in a local directory (manifest option, or the file
+ * child for -drive file=... usage), or as an explicit bottom-first
+ * "layers" array.
+ *
+ * See also the reference implementation:
+ * https://github.com/containerd/overlaybd
+ *
+ * Specifications for the on-disk formats:
+ *   LSMT layer blob format (header, data, index, trailer)
+ *     https://github.com/containerd/overlaybd/blob/main/src/overlaybd/lsmt/format_spec.md
+ *   ZFile block compression format (jump table, LZ4/ZSTD)
+ *     https://github.com/containerd/overlaybd/blob/main/src/overlaybd/zfile/format_spec.md
+ *
+ * The layer blobs are named by an OCI image manifest; the remaining OCI
+ * specifications are listed where the manifest is parsed:
+ *   https://github.com/opencontainers/image-spec/blob/main/manifest.md
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "qemu/osdep.h"
+#include "qapi/error.h"
+#include "block/block-io.h"
+#include "block/block_int.h"
+#include "block/qdict.h"
+#include "qemu/module.h"
+#include "qemu/bswap.h"
+#include "qemu/crc32c.h"
+#include "qemu/cutils.h"
+#include "qemu/memalign.h"
+#include "qobject/qjson.h"
+#include "qobject/qdict.h"
+#include "qobject/qlist.h"
+
+#ifdef CONFIG_LZ4
+#include <lz4.h>
+#endif
+#ifdef CONFIG_ZSTD
+#include <zstd.h>
+#endif
+
+/*
+ *
+ * On-disk format definitions below mirror the reference implementation
+ * (containerd/overlaybd: lsmt/file.cpp HeaderTrailer, lsmt/index.h,
+ * zfile/zfile.cpp HeaderTrailer, zfile/compressor.h CompressOptions)
+ * field for field; per-struct citations mark the source of each layout.
+ * Metadata is little-endian on disk and is read through explicit
+ * little-endian accessors, which also sidesteps the signedness of
+ * ldl_le_p() for the 32-bit fields.
+ */
+
+static const char lsmt_magic0[8] = "LSMT\0\1\2";
+static const uint8_t lsmt_magic1[16] = {
+    0x65, 0x7e, 0x63, 0xd2, 0x94, 0x44, 0x08, 0x4c,
+    0xa2, 0xd2, 0xc8, 0xec, 0x4f, 0xcf, 0xae, 0x8a
+};
+static const char zfile_magic0[8] = "ZFile\0\1";
+static const uint8_t zfile_magic1[16] = "tuji.yyf@Alibaba";
+
+/* LSMT::HeaderTrailer::SPACE, lsmt/file.cpp */
+#define LSMT_HT_SPACE             4096
+/* LSMT::MAX_LSMT_RO_INDEX_SIZE, lsmt/index.h */
+#define LSMT_MAX_RO_INDEX_ENTRIES 1000000LL
+/* LSMT::MAX_LSMT_INDEX_SIZE, lsmt/index.h: cap on the merged index */
+#define LSMT_MAX_MERGED_ENTRIES   128000000LL
+/* LSMT::MAX_STACK_LAYERS, lsmt/file.h */
+#define LSMT_MAX_LAYERS           255
+/* LSMT::Segment::INVALID_OFFSET, lsmt/index.h */
+#define LSMT_INVALID_OFFSET       (((uint64_t)1 << 50) - 1)
+#define LSMT_MOFFSET_BEGIN        (LSMT_HT_SPACE / BDRV_SECTOR_SIZE)
+
+/* LSMT::HeaderTrailer::FLAG_SHIFT_*, lsmt/file.cpp */
+#define LSMT_FLAG_HEADER    (1u << 0)
+#define LSMT_FLAG_DATA_FILE (1u << 1)
+#define LSMT_FLAG_SEALED    (1u << 2)
+
+/*
+ * LSMT::HeaderTrailer, lsmt/file.cpp: a packed struct with byte-aligned
+ * fields only (no bitfields), identical to the upstream definition less
+ * the C++ methods, and covering the whole LSMT_HT_SPACE block that is
+ * read and written.  Upstream stops at user_tag (sizeof == 390) and
+ * keeps the tail outside the type, reading the block into a raw buffer
+ * and casting it; format_spec.md:32 documents that tail as a header
+ * field, so it is declared here instead.
+ */
+typedef struct QEMU_PACKED OverlaybdLSMTHeader {
+    uint64_t magic0;
+    uint8_t magic1[16];
+    uint32_t size;           /* 390: the fields above, tail excluded */
+    uint32_t flags;
+    uint64_t index_offset;
+    uint64_t index_size;     /* # of 16-byte index entries */
+    uint64_t virtual_size;   /* bytes */
+    uint8_t uuid[37];        /* UUID::String */
+    uint8_t parent_uuid[37]; /* UUID::String */
+    uint16_t reserved;
+    uint8_t version;
+    uint8_t sub_version;
+    uint8_t user_tag[256];   /* TAG_SIZE */
+    uint8_t reserved_space[3706];
+} OverlaybdLSMTHeader;
+
+QEMU_BUILD_BUG_ON(sizeof(OverlaybdLSMTHeader) != LSMT_HT_SPACE);
+
+/* ZFile::HeaderTrailer::SPACE, zfile/zfile.cpp */
+#define ZFILE_HT_SPACE       512
+/*
+ * ZFile::MAX_READ_SIZE, zfile/zfile.h: upstream uses this one constant
+ * both as the block size cap and as the size of BlockReader's
+ * compressed-data read window.  Only the block size cap is needed here --
+ * the read window is sized to the request.
+ */
+#define ZFILE_MAX_BLOCK_SIZE 65536
+/*
+ * qemu-side hardening: a jump table entry is image-controlled, so cap the
+ * compressed size of one block to bound the read window.  Upstream has no
+ * span cap; it ERANGEs when a block overflows its 64 KiB window instead.
+ */
+#define ZFILE_MAX_SPAN       (1u << 20)
+/* ZFile::CompressOptions::LZ4 / ZSTD, zfile/compressor.h */
+#define ZFILE_ALGO_LZ4       1
+#define ZFILE_ALGO_ZSTD      2
+
+/* ZFile::HeaderTrailer::FLAG_SHIFT_*, zfile/zfile.cpp */
+#define ZFILE_FLAG_HEADER           (1ULL << 0)
+#define ZFILE_FLAG_DATA_FILE        (1ULL << 1)
+#define ZFILE_FLAG_SEALED           (1ULL << 2)
+#define ZFILE_FLAG_HEADER_OVERWRITE (1ULL << 3)
+#define ZFILE_FLAG_DIGEST           (1ULL << 4)
+#define ZFILE_FLAG_INDEX_COMPRESSED (1ULL << 5)
+
+/* ZFile::CompressOptions, zfile/compressor.h: sizeof == 24 */
+typedef struct QEMU_PACKED OverlaybdCompressOptions {
+    uint32_t block_size;
+    uint8_t algo;
+    uint8_t level;
+    uint8_t use_dict;
+    uint8_t __padding_0;
+    uint32_t reserved;
+    uint32_t dict_size;
+    uint8_t verify;
+    uint8_t __padding_1[7];
+} OverlaybdCompressOptions;
+
+/*
+ * ZFile::HeaderTrailer, zfile/zfile.cpp, embedding ZFile::CompressOptions
+ * at offset 72: upstream is not packed, but every member is naturally
+ * aligned at these offsets, so a packed C struct has the identical
+ * layout.  As with the LSMT header, the ZFILE_HT_SPACE tail that
+ * upstream keeps outside the type is declared here, per
+ * format_spec.md:39 (offset 89 ~ 511, of which opt.__padding_1 already
+ * covers 89 ~ 95).
+ */
+typedef struct QEMU_PACKED OverlaybdZFileHeader {
+    uint64_t magic0;
+    uint8_t magic1[16];
+    uint32_t size;           /* 96: the fields above, tail excluded */
+    uint32_t digest;         /* CRC32C of bytes 28..511, digest zeroed */
+    uint64_t flags;
+    uint64_t index_offset;
+    uint64_t index_size;     /* # of u32 jump-table entries (blocks) */
+    uint64_t original_file_size;
+    uint32_t index_crc;
+    uint32_t reserved_0;
+    OverlaybdCompressOptions opt;
+    uint8_t reserved_space[416];
+} OverlaybdZFileHeader;
+
+QEMU_BUILD_BUG_ON(sizeof(OverlaybdCompressOptions) != 24);
+QEMU_BUILD_BUG_ON(sizeof(OverlaybdZFileHeader) != ZFILE_HT_SPACE);
+
+/* ZFile::MAX_ZFILE_INDEX_SIZE, zfile/zfile.cpp */
+#define ZFILE_MAX_INDEX_ENTRIES 1000000000LL
+
+/* crc32c_salt()'s seed, zfile/zfile.cpp: per-block CRCs are salted */
+#define ZFILE_CRC_SALT          100007
+
+/*
+ * ZFile::JumpTable, zfile/zfile.cpp: within-group offsets are uint16_t,
+ * so one group spans at most UINT16_MAX + 1 bytes of compressed data.
+ */
+#define ZFILE_JUMP_GROUP_BYTES  (UINT16_MAX + 1u)
+
+#define ZFILE_INDEX_CHUNK_BYTES (1 << 20) /* ZFile::load_jump_table delta */
+#define ZFILE_INDEX_MAX_COROUTINES 32
+
+/*
+ * In-memory 16-byte index entry. The field assignment mirrors the
+ * on-disk SegmentMapping (lo = lba:50 | length:14, hi = moffset:55 |
+ * zeroed:1 | tag:8, the tag slot holding the serving layer at runtime,
+ * like the reference implementation), but this struct deliberately does
+ * NOT map the disk image: bitfields are forbidden in packed or
+ * exact-layout structures (docs/devel/style.rst), so on-disk bytes are
+ * decoded by overlaybd_decode_entry() with explicit little-endian loads
+ * and the bit order below is compiler-managed, never exposed.
+ */
+typedef struct OverlaybdSegment {
+    uint64_t lba:50;
+    uint64_t length:14;
+    uint64_t moffset:55;
+    uint64_t zeroed:1;
+    uint64_t layer:8;
+} OverlaybdSegment;
+
+QEMU_BUILD_BUG_ON(sizeof(OverlaybdSegment) != 16);
+
+typedef struct OverlaybdZfile {
+    uint64_t index_offset;       /* jump table offset, ZFile-relative bytes */
+    uint64_t index_size;
+    uint64_t original_file_size;
+    uint32_t block_size;
+    uint8_t algo;
+    bool verify;
+    /*
+     * ZFile::JumpTable, zfile/zfile.cpp: block @i starts at
+     * partial_offset[i >> group_shift] + deltas[i], except at a group
+     * boundary where the delta is zero.
+     */
+    uint64_t *partial_offset;
+    uint16_t *deltas;
+    unsigned group_shift;
+} OverlaybdZfile;
+
+typedef struct OverlaybdLayer {
+    BdrvChild *child;
+    bool is_zfile;
+    uint64_t base_offset; /* tar prefix length in the child, bytes */
+    uint64_t view_size;
+    OverlaybdZfile zf;
+    /* LSMT metadata, in ZFile-virtual space for compressed layers */
+    uint64_t index_offset;
+    OverlaybdSegment *index;
+    size_t index_count;
+    uint64_t virtual_size;
+} OverlaybdLayer;
+
+typedef struct BDRVOverlaybdState {
+    OverlaybdLayer *layers; /* top-first: layers[0] is the topmost layer */
+    int nb_layers;
+    OverlaybdSegment *merged;
+    size_t merged_cap;
+    size_t merged_count;
+    uint64_t virtual_size;
+} BDRVOverlaybdState;
+
+/*
+ * Load one on-disk 16-byte index entry (two little-endian u64:
+ * lo = lba:50 | length:14, hi = moffset:55 | zeroed:1 | tag:8) into the
+ * host-side entry. The on-disk tag is discarded; the merge assigns
+ * layers.
+ */
+static void overlaybd_decode_entry(const uint8_t *p, OverlaybdSegment *out)
+{
+    uint64_t lo = ldq_le_p(p);
+    uint64_t hi = ldq_le_p(p + 8);
+
+    out->lba = lo & (((uint64_t)1 << 50) - 1);
+    out->length = (lo >> 50) & ((1u << 14) - 1);
+    out->moffset = hi & (((uint64_t)1 << 55) - 1);
+    out->zeroed = (hi >> 55) & 1;
+    out->layer = 0;
+}
+
+static bool overlaybd_has_magic(const void *ht, const void *magic0,
+                                const void *magic1)
+{
+    return memcmp(ht, magic0, 8) == 0 &&
+           memcmp((const uint8_t *)ht + 8, magic1, 16) == 0;
+}
+
+static bool overlaybd_is_lsmt_header(const uint8_t *buf)
+{
+    return overlaybd_has_magic(buf, lsmt_magic0, lsmt_magic1);
+}
+
+static bool overlaybd_is_zfile_header(const uint8_t *buf)
+{
+    return overlaybd_has_magic(buf, zfile_magic0, zfile_magic1);
+}
+
+static bool overlaybd_is_tar_header(const uint8_t *hdr)
+{
+    return memcmp(hdr + 257, "ustar", 5) == 0 &&
+           (hdr[156] == '0' || hdr[156] == '\0' || hdr[156] == 'x');
+}
+
+static bool overlaybd_verify_lsmt_block(const OverlaybdLSMTHeader *ht,
+                                        bool is_trailer)
+{
+    uint32_t flags;
+
+    if (!overlaybd_has_magic(ht, lsmt_magic0, lsmt_magic1)) {
+        return false;
+    }
+    flags = le32_to_cpu(ht->flags);
+    if (is_trailer) {
+        return !(flags & LSMT_FLAG_HEADER) &&
+                (flags & LSMT_FLAG_DATA_FILE) &&
+                (flags & LSMT_FLAG_SEALED);
+    }
+    return flags & LSMT_FLAG_HEADER;
+}
+
+/*
+ * Upstream's crc32::crc32c_extend() is the raw CRC-32C recurrence with no
+ * post-conditioning, while qemu's crc32c() inverts once on return, so XOR
+ * the result to recover upstream's value for any seed. A wrong convention
+ * here fails silently against real images: nothing but the digest mismatches.
+ */
+static uint32_t overlaybd_crc32c(uint32_t seed, const void *buf, size_t len)
+{
+    return crc32c(seed, buf, len) ^ 0xffffffff;
+}
+
+static bool overlaybd_zfile_verify_digest(OverlaybdZFileHeader *blk)
+{
+    uint32_t crc, saved = le32_to_cpu(blk->digest);
+
+    blk->digest = 0;
+    crc = overlaybd_crc32c(0, blk, sizeof(*blk));
+    blk->digest = cpu_to_le32(saved);
+    return crc == saved;
+}
+
+static bool overlaybd_zfile_verify_block(OverlaybdZFileHeader *blk,
+                                         bool is_trailer)
+{
+    uint64_t flags;
+
+    if (!overlaybd_has_magic(blk, zfile_magic0, zfile_magic1)) {
+        return false;
+    }
+    flags = le64_to_cpu(blk->flags);
+    if (is_trailer) {
+        if ((flags & ZFILE_FLAG_HEADER) || !(flags & ZFILE_FLAG_DATA_FILE) ||
+            !(flags & ZFILE_FLAG_SEALED)) {
+            return false;
+        }
+    } else {
+        if (!(flags & ZFILE_FLAG_HEADER)) {
+            return false;
+        }
+    }
+    if (flags & ZFILE_FLAG_DIGEST && !overlaybd_zfile_verify_digest(blk)) {
+        return false;
+    }
+    return true;
+}
+
+/*
+ * Locate a header/trailer block of @ht_space bytes near the end of the
+ * layer and return its offset, relative to the child, or -errno.  The
+ * block found is also copied to @ht_out, which must hold @ht_space bytes:
+ * it has already been read and validated here, so re-reading it at the
+ * returned offset would be a second I/O for the same bytes.
+ *
+ * The end of the layer is not the end of the child: a tar wrapper pads
+ * its entry to a blocking factor, so the scan must stop at
+ * base_offset + view_size. That end is still not exact for a bare layer,
+ * whose view_size is just bdrv_getlength() rounded up to 512 bytes, so
+ * scan the last @ht_space + 511 bytes for the magic (the same approach
+ * as dmg_find_koly_offset()), taking the match closest to the end.
+ */
+static int64_t GRAPH_RDLOCK
+overlaybd_scan_trailer(OverlaybdLayer *l, size_t ht_space, bool is_zfile,
+                       void *ht_out)
+{
+    int64_t end = l->base_offset + l->view_size;
+    int64_t window, off, found = -ENOENT;
+    uint8_t *buf;
+    int ret;
+
+    window = (int64_t)ht_space + 511;
+    off = end > window ? end - window : 0;
+    window = end - off;
+
+    buf = g_malloc(window);
+    ret = bdrv_pread(l->child, off, window, buf, 0);
+    if (ret < 0) {
+        g_free(buf);
+        return ret;
+    }
+    for (int64_t i = (int64_t)window - ht_space; i >= 0; i--) {
+        bool ok;
+
+        /* copy the whole block: the ZFile digest covers all 512 bytes */
+        if (is_zfile) {
+            OverlaybdZFileHeader blk;
+
+            memcpy(&blk, buf + i, sizeof(blk));
+            ok = overlaybd_zfile_verify_block(&blk, true);
+        } else {
+            OverlaybdLSMTHeader blk;
+
+            memcpy(&blk, buf + i, sizeof(blk));
+            ok = overlaybd_verify_lsmt_block(&blk, true);
+        }
+        if (ok) {
+            memcpy(ht_out, buf + i, ht_space);
+            found = off + i;
+            break;
+        }
+    }
+    g_free(buf);
+    return found;
+}
+
+/*
+ * ZFile::JumpTable::build(), zfile/zfile.cpp: turn the on-disk u32
+ * block lengths into one absolute offset per group plus uint16_t
+ * within-group prefix sums. @offset_begin is where block 0 lives,
+ * relative to the ZFile.
+ */
+static int overlaybd_zfile_build_table(OverlaybdZfile *zf,
+                                       const uint32_t *table,
+                                       uint64_t offset_begin, Error **errp)
+{
+    uint64_t group_size = ZFILE_JUMP_GROUP_BYTES / zf->block_size;
+    uint64_t min_span = zf->verify ? 4 : 0;
+    uint64_t raw_offset = offset_begin;
+    uint32_t span;
+
+    zf->group_shift = ctz64(group_size);
+    /*
+     * g_try_new, not g_new: both sizes come from the image, and a hostile
+     * header can ask for gigabytes (at block_size 65536, group_size is 1
+     * and partial_offset costs 8 bytes per entry).
+     */
+    zf->partial_offset = g_try_new(uint64_t, zf->index_size / group_size + 1);
+    zf->deltas = g_try_new(uint16_t, zf->index_size + 1);
+    if (!zf->partial_offset || !zf->deltas) {
+        return -ENOMEM;
+    }
+    zf->partial_offset[0] = raw_offset;
+    zf->deltas[0] = 0;
+
+    for (uint64_t i = 1; i <= zf->index_size; i++) {
+        span = ldl_le_p((const uint8_t *)&table[i - 1]);
+
+        if (span <= min_span || span > ZFILE_MAX_SPAN) {
+            error_setg(errp, "invalid ZFile block size in jump table");
+            return -EINVAL;
+        }
+        raw_offset += span;
+        if (i % group_size == 0) {
+            zf->partial_offset[i / group_size] = raw_offset;
+            zf->deltas[i] = 0;
+            continue;
+        }
+        if ((uint64_t)zf->deltas[i - 1] + span >= UINT16_MAX) {
+            error_setg(errp, "ZFile compressed blocks in one jump table "
+                             "group exceed %d bytes", UINT16_MAX);
+            return -ERANGE;
+        }
+        zf->deltas[i] = zf->deltas[i - 1] + span;
+    }
+    return 0;
+}
+
+/* ZFile::JumpTable::operator[], zfile/zfile.cpp */
+static uint64_t overlaybd_zfile_block_off(const OverlaybdZfile *zf,
+                                          uint64_t idx)
+{
+    uint64_t part = zf->partial_offset[idx >> zf->group_shift];
+
+    if (idx & ((1ULL << zf->group_shift) - 1)) {
+        return part + zf->deltas[idx];
+    }
+    return part;
+}
+
+typedef struct OverlaybdZfileLoad {
+    BdrvChild *child;
+    int64_t offset;   /* table start, relative to the child */
+    uint8_t *table;
+    uint64_t bytes;   /* table size */
+    uint64_t next;    /* first byte no coroutine has claimed yet */
+    int nb_done;
+    int ret;
+} OverlaybdZfileLoad;
+
+/*
+ * The caller holds the graph rdlock across the whole open, so these
+ * coroutines take no further lock. They share the one cursor in @load:
+ * claiming a chunk reads load->next then advances it with no yield between,
+ * and coroutines in one AioContext never run concurrently, so the chunks are
+ * partitioned with no locking. Inputs are copied to locals here; only next,
+ * ret and nb_done stay shared (a private next would spin on chunk 0 forever).
+ */
+static void coroutine_fn GRAPH_RDLOCK
+overlaybd_zfile_load_co(void *opaque)
+{
+    OverlaybdZfileLoad *load = opaque;
+    BdrvChild *child = load->child;
+    int64_t offset = load->offset;
+    uint8_t *table = load->table;
+    uint64_t bytes = load->bytes;
+
+    while (load->next < bytes) {
+        uint64_t off = load->next;
+        uint64_t len = MIN(bytes - off, (uint64_t)ZFILE_INDEX_CHUNK_BYTES);
+        int ret;
+
+        load->next = off + len;
+
+        ret = bdrv_co_pread(child, offset + off, len, table + off, 0);
+        if (ret < 0) {
+            if (load->ret == 0) {
+                load->ret = ret;
+            }
+            break;
+        }
+    }
+    load->nb_done++;
+}
+
+static int GRAPH_RDLOCK
+overlaybd_zfile_load_table(OverlaybdLayer *l, uint32_t *table, Error **errp)
+{
+    OverlaybdZfile *zf = &l->zf;
+    uint64_t bytes = zf->index_size * sizeof(uint32_t);
+    int ret;
+
+    if (bytes <= ZFILE_INDEX_CHUNK_BYTES) {
+        ret = bdrv_pread(l->child, l->base_offset + zf->index_offset,
+                         bytes, table, 0);
+    } else {
+        OverlaybdZfileLoad load = {
+            .child = l->child,
+            .offset = l->base_offset + zf->index_offset,
+            .table = (uint8_t *)table,
+            .bytes = bytes,
+        };
+        int nb_chunks = DIV_ROUND_UP(bytes, ZFILE_INDEX_CHUNK_BYTES);
+        int nb_cos = MIN(nb_chunks, ZFILE_INDEX_MAX_COROUTINES);
+
+        for (int i = 0; i < nb_cos; i++) {
+            qemu_coroutine_enter(qemu_coroutine_create(overlaybd_zfile_load_co,
+                                                       &load));
+        }
+        BDRV_POLL_WHILE(l->child->bs, load.nb_done < nb_cos);
+        ret = load.ret;
+    }
+    if (ret < 0) {
+        error_setg_errno(errp, -ret, "could not read ZFile jump table");
+    }
+    return ret;
+}
+
+static int GRAPH_RDLOCK
+overlaybd_zfile_open(OverlaybdLayer *l, Error **errp)
+{
+    BdrvChild *child = l->child;
+    OverlaybdZfile *zf = &l->zf;
+    OverlaybdZFileHeader ht;
+    uint64_t flags, index_offset, index_size, original_file_size;
+    uint32_t *table = NULL;
+    uint32_t block_size;
+    uint8_t algo, verify;
+    int64_t child_size, zfile_end;
+    uint64_t end_reserve;
+    int ret;
+
+    child_size = bdrv_getlength(child->bs);
+    if (child_size < 0) {
+        error_setg_errno(errp, -child_size, "could not get layer file size");
+        return child_size;
+    }
+    child_size -= l->base_offset;
+    if (child_size < 2 * ZFILE_HT_SPACE) {
+        error_setg(errp, "layer file too small to be a ZFile");
+        return -EINVAL;
+    }
+
+    ret = bdrv_pread(child, l->base_offset, sizeof(ht), &ht, 0);
+    if (ret < 0) {
+        error_setg_errno(errp, -ret, "could not read ZFile header");
+        return ret;
+    }
+    if (!overlaybd_zfile_verify_block(&ht, false)) {
+        error_setg(errp, "invalid ZFile header");
+        return -EINVAL;
+    }
+
+    flags = le64_to_cpu(ht.flags);
+    /*
+     * The layer's own end, not the child's: a tar wrapper pads its entry.
+     * bdrv_getlength() also rounds up, so when there is a trailer the
+     * scanned trailer position gives the exact end instead.
+     */
+    zfile_end = l->view_size;
+    end_reserve = 0;
+    if (!(flags & ZFILE_FLAG_HEADER_OVERWRITE)) {
+        /*
+         * Without HEADER_OVERWRITE the trailer carries the final metadata,
+         * so the header is dead once its flags have been read and the
+         * trailer is read into the same block -- as upstream does.
+         */
+        int64_t trl_off = overlaybd_scan_trailer(l, ZFILE_HT_SPACE, true,
+                                                 &ht);
+
+        if (trl_off < 0) {
+            error_setg(errp, "invalid ZFile trailer");
+            return -EINVAL;
+        }
+        flags = le64_to_cpu(ht.flags);
+        zfile_end = trl_off + ZFILE_HT_SPACE - (int64_t)l->base_offset;
+        end_reserve = ZFILE_HT_SPACE;
+    }
+
+    if (flags & ZFILE_FLAG_INDEX_COMPRESSED) {
+        error_setg(errp, "compressed ZFile jump table is not supported");
+        return -ENOTSUP;
+    }
+
+    index_offset = le64_to_cpu(ht.index_offset);
+    index_size = le64_to_cpu(ht.index_size);
+    original_file_size = le64_to_cpu(ht.original_file_size);
+    block_size = le32_to_cpu(ht.opt.block_size);
+    algo = ht.opt.algo;
+    verify = ht.opt.verify;
+
+    if (ht.opt.use_dict != 0) {
+        error_setg(errp, "ZFile with dictionary is not supported");
+        return -ENOTSUP;
+    }
+    if (block_size == 0 || block_size > ZFILE_MAX_BLOCK_SIZE ||
+        !is_power_of_2(block_size)) {
+        error_setg(errp, "invalid ZFile block size %" PRIu32, block_size);
+        return -EINVAL;
+    }
+    if (algo != ZFILE_ALGO_LZ4 && algo != ZFILE_ALGO_ZSTD) {
+        error_setg(errp, "unknown ZFile compression algorithm %" PRIu8, algo);
+        return -EINVAL;
+    }
+#ifndef CONFIG_LZ4
+    if (algo == ZFILE_ALGO_LZ4) {
+        error_setg(errp, "LZ4-compressed layer, but qemu was compiled "
+                         "without lz4 support");
+        return -ENOTSUP;
+    }
+#endif
+#ifndef CONFIG_ZSTD
+    if (algo == ZFILE_ALGO_ZSTD) {
+        error_setg(errp, "ZSTD-compressed layer, but qemu was compiled "
+                         "without zstd support");
+        return -ENOTSUP;
+    }
+#endif
+
+    if (index_size > ZFILE_MAX_INDEX_ENTRIES) {
+        error_setg(errp, "ZFile index size %" PRIu64 " exceeds maximum "
+                         "%lld", index_size, ZFILE_MAX_INDEX_ENTRIES);
+        return -EINVAL;
+    }
+    /*
+     * order the checks so each subtraction is guarded by the previous
+     * condition (no unsigned wraparound on adversarial values)
+     */
+    if (index_offset < ZFILE_HT_SPACE ||
+        index_offset > (uint64_t)zfile_end - end_reserve ||
+        index_size * 4 > (uint64_t)zfile_end - end_reserve - index_offset) {
+        error_setg(errp, "invalid ZFile jump table location");
+        return -EINVAL;
+    }
+    if ((index_size - 1) * block_size >= original_file_size ||
+        original_file_size > index_size * block_size) {
+        error_setg(errp, "ZFile block count does not match content size");
+        return -EINVAL;
+    }
+
+    zf->index_offset = index_offset;
+    zf->index_size = index_size;
+    zf->original_file_size = original_file_size;
+    zf->block_size = block_size;
+    zf->algo = algo;
+    zf->verify = verify;
+
+    table = qemu_try_blockalign(l->child->bs,
+                                index_size * sizeof(uint32_t));
+    if (!table) {
+        return -ENOMEM;
+    }
+    ret = overlaybd_zfile_load_table(l, table, errp);
+    if (ret < 0) {
+        goto out;
+    }
+    /*
+     * like the reference implementation, the jump table CRC is only
+     * checked when the digest flag is set (it is garbage otherwise)
+     */
+    if ((flags & ZFILE_FLAG_DIGEST) && le32_to_cpu(ht.index_crc) !=
+        overlaybd_crc32c(0, table, index_size * sizeof(uint32_t))) {
+        error_setg(errp, "ZFile jump table CRC mismatch");
+        ret = -EIO;
+        goto out;
+    }
+
+    ret = overlaybd_zfile_build_table(zf, table,
+                                      ZFILE_HT_SPACE +
+                                      le32_to_cpu(ht.opt.dict_size), errp);
+    if (ret < 0) {
+        goto out;
+    }
+    if (overlaybd_zfile_block_off(zf, zf->index_size) > zf->index_offset) {
+        error_setg(errp, "ZFile data region overlaps the jump table");
+        ret = -EINVAL;
+        goto out;
+    }
+
+    l->view_size = zf->original_file_size;
+    ret = 0;
+out:
+    qemu_vfree(table);
+    return ret;
+}
+
+/*
+ * Forward-only cursor into a QEMUIOVector. Seeding it and committing each
+ * block's bytes are the same advance, so they share one. The blocks of a
+ * request are written contiguously in increasing order, so a full pass costs
+ * O(niov) rather than the O(niov * nblocks) of qemu_iovec_subvec_niov(),
+ * which rescans from iov[0] -- expensive for the one-iovec-per-guest-page
+ * qiov that virtio-blk normally hands us.
+ */
+typedef struct OverlaybdQiovCursor {
+    struct iovec *iov;
+    struct iovec *end;
+    uint8_t *cur;
+    size_t left;
+} OverlaybdQiovCursor;
+
+static void overlaybd_qiov_cursor_advance(OverlaybdQiovCursor *c, size_t n)
+{
+    while (c->cur && n >= c->left) {
+        n -= c->left;
+        if (++c->iov >= c->end) {
+            c->cur = NULL;
+            c->left = 0;
+            return;
+        }
+        c->cur = (uint8_t *)c->iov->iov_base;
+        c->left = c->iov->iov_len;
+    }
+    if (c->cur) {
+        c->cur += n;
+        c->left -= n;
+    }
+}
+
+/*
+ * Point @c at byte 0 of @qiov.  @qiov is NULL on the buf path, which
+ * leaves the cursor exhausted, so every test of it fails closed.
+ */
+static void overlaybd_qiov_cursor_init(OverlaybdQiovCursor *c,
+                                       QEMUIOVector *qiov)
+{
+    c->iov = qiov ? qiov->iov : NULL;
+    c->end = qiov ? qiov->iov + qiov->niov : NULL;
+    c->cur = c->iov < c->end ? (uint8_t *)c->iov->iov_base : NULL;
+    c->left = c->cur ? c->iov->iov_len : 0;
+}
+
+/*
+ * All I/O goes through bdrv_pread() (a mixed wrapper), so this is
+ * callable from both GS and coroutine contexts.
+ */
+static int coroutine_mixed_fn GRAPH_RDLOCK
+overlaybd_zfile_read(OverlaybdLayer *l, uint64_t off, uint64_t bytes,
+                     void *buf, QEMUIOVector *qiov, size_t qiov_off)
+{
+    OverlaybdZfile *zf = &l->zf;
+    uint32_t bsz = zf->block_size;
+    uint64_t first, last, base, need;
+    uint8_t *cbuf = NULL;
+    uint8_t *scratch = NULL;
+    OverlaybdQiovCursor c;
+    int ret;
+
+    assert(off + bytes <= zf->original_file_size);
+
+    /*
+     * also keeps "off + bytes - 1" below from wrapping when both are 0,
+     * which a caller reading an empty index can legitimately ask for
+     */
+    if (bytes == 0) {
+        return 0;
+    }
+
+    first = off / bsz;
+    last = (off + bytes - 1) / bsz;
+    /*
+     * One read covers the compressed bytes of every block the request spans,
+     * so the loop below never refills. A hostile jump table can still make
+     * @need many times larger than @bytes, hence qemu_try_blockalign() rather
+     * than the aborting qemu_blockalign(); aligning to the child's
+     * opt_mem_alignment lets an already-aligned request reach the protocol
+     * driver without being bounced.
+     */
+    base = overlaybd_zfile_block_off(zf, first);
+    need = overlaybd_zfile_block_off(zf, last + 1) - base;
+
+    /*
+     * One allocation for both: the compressed window, then the one-block
+     * decompression scratch behind it. bdrv_pread() writes only the first
+     * @need bytes so the two never overlap, and only the window goes to the
+     * block layer, so only its start needs the alignment.
+     */
+    cbuf = qemu_try_blockalign(l->child->bs, need + bsz);
+    if (!cbuf) {
+        return -ENOMEM;
+    }
+    scratch = cbuf + need;
+
+    ret = bdrv_pread(l->child, l->base_offset + base, need, cbuf, 0);
+    if (ret < 0) {
+        goto out;
+    }
+
+    overlaybd_qiov_cursor_init(&c, qiov);
+    overlaybd_qiov_cursor_advance(&c, qiov_off);
+
+    for (uint64_t bi = first; bi <= last; bi++) {
+        uint64_t bstart = bi * (uint64_t)bsz;
+        uint64_t expected = MIN((uint64_t)bsz,
+                                zf->original_file_size - bstart);
+        uint64_t cp_begin = bstart > off ? 0 : off - bstart;
+        uint64_t cp_len = MIN(expected, off + bytes - bstart) - cp_begin;
+        uint64_t dst_off = bstart + cp_begin - off;
+        uint64_t off_blk = overlaybd_zfile_block_off(zf, bi);
+        uint64_t span = overlaybd_zfile_block_off(zf, bi + 1) - off_blk;
+        uint64_t clen = span - (zf->verify ? 4 : 0);
+        const uint8_t *cdata;
+        uint8_t *dst = NULL, *dec;
+
+        cdata = cbuf + (off_blk - base);
+
+        /*
+         * BlockReader::crc32_code(), zfile/zfile.cpp: the salted CRC trails
+         * the compressed data and covers exactly those bytes.  Upstream
+         * retries three times before failing, which cannot help against a
+         * read-only child.
+         */
+        if (zf->verify) {
+            uint32_t want = ldl_le_p(cdata + clen);
+
+            if (want != overlaybd_crc32c(ZFILE_CRC_SALT, cdata, clen)) {
+                ret = -EIO;
+                goto out;
+            }
+        }
+
+        if (cp_begin == 0 && cp_len == expected) {
+            if (buf) {
+                dst = (uint8_t *)buf + dst_off;
+            } else if (c.cur && cp_len <= c.left) {
+                dst = c.cur;
+            }
+        }
+        dec = dst ? dst : scratch;
+
+        ret = -EIO;
+#ifdef CONFIG_LZ4
+        if (zf->algo == ZFILE_ALGO_LZ4) {
+            int r = LZ4_decompress_safe((const char *)cdata, (char *)dec,
+                                        clen, expected);
+
+            if (r >= 0 && (uint64_t)r == expected) {
+                ret = 0;
+            }
+        }
+#endif
+#ifdef CONFIG_ZSTD
+        if (zf->algo == ZFILE_ALGO_ZSTD) {
+            size_t r = ZSTD_decompress(dec, expected, cdata, clen);
+
+            if (!ZSTD_isError(r) && r == expected) {
+                ret = 0;
+            }
+        }
+#endif
+        if (ret < 0) {
+            goto out;
+        }
+        if (!dst) {
+            if (buf) {
+                memcpy((uint8_t *)buf + dst_off, scratch + cp_begin, cp_len);
+            } else {
+                qemu_iovec_from_buf(qiov, qiov_off + dst_off,
+                                    scratch + cp_begin, cp_len);
+            }
+        }
+
+        overlaybd_qiov_cursor_advance(&c, cp_len);
+    }
+
+    ret = 0;
+out:
+    qemu_vfree(cbuf);
+    return ret;
+}
+
+static int GRAPH_RDLOCK
+overlaybd_pread_view(OverlaybdLayer *l, uint64_t off, int64_t bytes, void *buf)
+{
+    if (l->is_zfile) {
+        return overlaybd_zfile_read(l, off, bytes, buf, NULL, 0);
+    }
+    return bdrv_pread(l->child, l->base_offset + off, bytes, buf, 0);
+}
+
+/*
+ * Load and validate the LSMT header/trailer/index of a layer. All
+ * metadata comes from the trailer; the header is only a format sniff
+ * (real images may have garbage header fields).
+ */
+static int GRAPH_RDLOCK
+overlaybd_lsmt_open(OverlaybdLayer *l, Error **errp)
+{
+    OverlaybdLSMTHeader ht;
+    OverlaybdSegment *index = NULL;
+    uint8_t *raw = NULL;
+    uint64_t index_offset, index_size, virtual_size, moffset_end;
+    uint64_t index_bound;
+    uint64_t prev_end = 0;
+    size_t count = 0;
+    int ret;
+
+    if (l->view_size < 2 * LSMT_HT_SPACE) {
+        error_setg(errp, "layer too small to be an LSMT image");
+        return -EINVAL;
+    }
+
+    ret = overlaybd_pread_view(l, 0, LSMT_HT_SPACE, &ht);
+    if (ret < 0) {
+        error_setg_errno(errp, -ret, "could not read LSMT header");
+        return ret;
+    }
+    if (!overlaybd_verify_lsmt_block(&ht, false)) {
+        error_setg(errp, "invalid LSMT header");
+        return -EINVAL;
+    }
+
+    /*
+     * The header is only a format sniff, so reading the trailer into the
+     * same block is safe.  index_bound is where the index must end: the
+     * trailer start, which for a bare layer is not view_size - SPACE
+     * because bdrv_getlength() over-reports (tar padding, rounding).
+     */
+    if (l->is_zfile) {
+        /* in the ZFile virtual space the content size is exact */
+        ret = overlaybd_pread_view(l, l->view_size - LSMT_HT_SPACE,
+                                   LSMT_HT_SPACE, &ht);
+        if (ret < 0) {
+            error_setg_errno(errp, -ret, "could not read LSMT trailer");
+            return ret;
+        }
+        index_bound = l->view_size - LSMT_HT_SPACE;
+    } else {
+        int64_t trl_off = overlaybd_scan_trailer(l, LSMT_HT_SPACE, false,
+                                                 &ht);
+
+        if (trl_off < 0) {
+            error_setg(errp, "invalid LSMT trailer (not a sealed data "
+                             "layer?)");
+            return trl_off < -ENOENT ? trl_off : -EINVAL;
+        }
+        index_bound = (uint64_t)(trl_off - (int64_t)l->base_offset);
+    }
+    if (!overlaybd_verify_lsmt_block(&ht, true)) {
+        error_setg(errp, "invalid LSMT trailer (not a sealed data layer?)");
+        return -EINVAL;
+    }
+
+    index_offset = le64_to_cpu(ht.index_offset);
+    index_size = le64_to_cpu(ht.index_size);
+    virtual_size = le64_to_cpu(ht.virtual_size);
+
+    if (index_size > LSMT_MAX_RO_INDEX_ENTRIES) {
+        error_setg(errp, "LSMT index of %" PRIu64 " entries exceeds maximum "
+                         "%" PRId64, index_size, LSMT_MAX_RO_INDEX_ENTRIES);
+        return -EINVAL;
+    }
+    if (index_offset < LSMT_HT_SPACE ||
+        index_offset > index_bound ||
+        index_size * 16 > index_bound - index_offset) {
+        error_setg(errp, "invalid LSMT index location");
+        return -EINVAL;
+    }
+
+    /*
+     * both sizes come from the image, so the allocating variants that
+     * abort on OOM are not acceptable here; out: frees either pointer
+     */
+    index = g_try_new(OverlaybdSegment, index_size);
+    raw = qemu_try_blockalign(l->child->bs, index_size * 16);
+    if (!index || !raw) {
+        ret = -ENOMEM;
+        goto out;
+    }
+    ret = overlaybd_pread_view(l, index_offset, index_size * 16, raw);
+    if (ret < 0) {
+        error_setg_errno(errp, -ret, "could not read LSMT index");
+        goto out;
+    }
+
+    moffset_end = index_offset / BDRV_SECTOR_SIZE;
+    for (uint64_t i = 0; i < index_size; i++) {
+        OverlaybdSegment seg, *out;
+
+        overlaybd_decode_entry(raw + i * 16, &seg);
+        if (seg.lba == LSMT_INVALID_OFFSET || seg.length == 0) {
+            continue;
+        }
+        if (count > 0 && seg.lba < prev_end) {
+            error_setg(errp, "LSMT index is disordered or overlapping");
+            ret = -EINVAL;
+            goto out;
+        }
+        if (seg.zeroed) {
+            if (seg.moffset < LSMT_MOFFSET_BEGIN ||
+                seg.moffset > moffset_end) {
+                error_setg(errp, "LSMT zeroed entry moffset out of range");
+                ret = -EINVAL;
+                goto out;
+            }
+        } else {
+            if (seg.moffset < LSMT_MOFFSET_BEGIN ||
+                seg.moffset >= moffset_end ||
+                seg.moffset + seg.length > moffset_end) {
+                error_setg(errp, "LSMT entry moffset out of range");
+                ret = -EINVAL;
+                goto out;
+            }
+        }
+        out = &index[count++];
+        *out = seg;
+        prev_end = seg.lba + seg.length;
+    }
+
+    l->index_offset = index_offset;
+    l->index = index;
+    index = NULL;
+    l->index_count = count;
+    l->virtual_size = virtual_size;
+    ret = 0;
+out:
+    qemu_vfree(raw);
+    g_free(index);
+    return ret;
+}
+
+static int GRAPH_RDLOCK
+overlaybd_detect_tar(OverlaybdLayer *l, int64_t child_size, Error **errp)
+{
+    uint8_t hdr[512];
+    uint64_t hoff = 0;
+    int ret;
+
+    l->base_offset = 0;
+    l->view_size = child_size;
+
+    if (child_size < 1024) {
+        return 0;
+    }
+    ret = bdrv_pread(l->child, 0, sizeof(hdr), hdr, 0);
+    if (ret < 0) {
+        error_setg_errno(errp, -ret, "could not read layer file header");
+        return ret;
+    }
+    if (memcmp(hdr + 257, "ustar", 5) != 0) {
+        return 0;
+    }
+    if (!overlaybd_is_tar_header(hdr)) {
+        error_setg(errp, "unsupported tar layer layout");
+        return -EINVAL;
+    }
+    if (hdr[156] == 'x') {
+        /*
+         * pax extended header: its size field gives the length of the
+         * attribute block that precedes the real ustar header
+         */
+        char size[13];
+        unsigned long long pax_size;
+
+        memcpy(size, hdr + 124, 12);
+        size[12] = 0;
+        if (qemu_strtou64(size, NULL, 8, &pax_size) < 0) {
+            pax_size = 0;
+        }
+        hoff = ZFILE_HT_SPACE +
+               DIV_ROUND_UP(pax_size, ZFILE_HT_SPACE) * ZFILE_HT_SPACE;
+        if (hoff + 2 * ZFILE_HT_SPACE > (uint64_t)child_size) {
+            error_setg(errp, "tar layer too small");
+            return -EINVAL;
+        }
+        ret = bdrv_pread(l->child, hoff, sizeof(hdr), hdr, 0);
+        if (ret < 0) {
+            error_setg_errno(errp, -ret, "could not read tar file header");
+            return ret;
+        }
+        if (memcmp(hdr + 257, "ustar", 5) != 0 ||
+            (hdr[156] != '0' && hdr[156] != '\0')) {
+            error_setg(errp, "unsupported tar layer layout");
+            return -EINVAL;
+        }
+    }
+
+    l->base_offset = hoff + ZFILE_HT_SPACE;
+    l->view_size = child_size - l->base_offset;
+    if (hdr[124] < 0x80) {
+        /*
+         * prefer the tar entry size: it is authoritative when the tar
+         * entry is padded
+         */
+        char size[13];
+        unsigned long long tar_size;
+
+        memcpy(size, hdr + 124, 12);
+        size[12] = 0;
+        if (!qemu_strtou64(size, NULL, 8, &tar_size) &&
+            tar_size > 0 && tar_size <= l->view_size) {
+            l->view_size = tar_size;
+        }
+    }
+    return 0;
+}
+
+static int GRAPH_RDLOCK
+overlaybd_open_layer(OverlaybdLayer *l, Error **errp)
+{
+    uint8_t buf[512];
+    int64_t child_size;
+    int ret;
+
+    child_size = bdrv_getlength(l->child->bs);
+    if (child_size < 0) {
+        error_setg_errno(errp, -child_size, "could not get layer file size");
+        return child_size;
+    }
+
+    ret = overlaybd_detect_tar(l, child_size, errp);
+    if (ret < 0) {
+        return ret;
+    }
+    if (l->view_size < 2 * ZFILE_HT_SPACE) {
+        error_setg(errp, "layer file too small");
+        return -EINVAL;
+    }
+
+    ret = bdrv_pread(l->child, l->base_offset, sizeof(buf), buf, 0);
+    if (ret < 0) {
+        error_setg_errno(errp, -ret, "could not read layer file header");
+        return ret;
+    }
+    if (overlaybd_is_zfile_header(buf) &&
+        (ldq_le_p(buf + 32) & ZFILE_FLAG_HEADER)) {
+        l->is_zfile = true;
+        ret = overlaybd_zfile_open(l, errp);
+        if (ret < 0) {
+            return ret;
+        }
+    }
+
+    return overlaybd_lsmt_open(l, errp);
+}
+
+/*
+ * First index whose entry covers or follows @sector: entries are sorted
+ * and non-overlapping, so entry ends are monotonically increasing.
+ * This matches the reference implementation's lower_bound(), which
+ * searches by entry end so that an entry covering @sector is found.
+ */
+static size_t overlaybd_find_segment(const OverlaybdSegment *a, size_t n,
+                                     uint64_t sector)
+{
+    size_t lo = 0, hi = n;
+
+    while (lo < hi) {
+        size_t mid = (lo + hi) / 2;
+
+        if (a[mid].lba + a[mid].length <= sector) {
+            lo = mid + 1;
+        } else {
+            hi = mid;
+        }
+    }
+    return lo;
+}
+
+/*
+ * Merge the per-layer indexes into s->merged: layers[0] is the topmost
+ * layer; a layer's mapping wins its whole extent, lower layers fill the
+ * holes. Entries pushed for [begin, end) are edge-trimmed to that range.
+ */
+static bool overlaybd_merge_level(BDRVOverlaybdState *s, int level,
+                                  uint64_t begin, uint64_t end, size_t *count)
+{
+    OverlaybdLayer *l;
+    size_t i, size0 = *count;
+    uint64_t begin0 = begin;
+
+    if (level >= s->nb_layers || begin >= end) {
+        return true;
+    }
+    l = &s->layers[level];
+    for (i = overlaybd_find_segment(l->index, l->index_count, begin);
+         i < l->index_count && l->index[i].lba < end; i++) {
+        OverlaybdSegment *e = &l->index[i];
+
+        if (e->lba > begin &&
+            !overlaybd_merge_level(s, level + 1, begin, e->lba, count)) {
+            return false;
+        }
+        if (*count == s->merged_cap) {
+            /*
+             * One input entry can be emitted several times when it is
+             * split by ranges covered (or zeroed) at upper layers, so the
+             * output can exceed the sum of the input indexes -- hence the
+             * cap, which upstream enforces at the same point.  Grow into a
+             * temporary so that a failed realloc leaves s->merged valid
+             * for overlaybd_close() to free.
+             */
+            size_t cap = s->merged_cap ? s->merged_cap * 2 : 64;
+            OverlaybdSegment *grown;
+
+            if (cap > LSMT_MAX_MERGED_ENTRIES) {
+                return false;
+            }
+            grown = g_try_renew(OverlaybdSegment, s->merged, cap);
+            if (!grown) {
+                return false;
+            }
+            s->merged = grown;
+            s->merged_cap = cap;
+        }
+        s->merged[*count] = *e;
+        s->merged[*count].layer = level;
+        (*count)++;
+        begin = e->lba + e->length;
+    }
+    if (begin < end &&
+        !overlaybd_merge_level(s, level + 1, begin, end, count)) {
+        return false;
+    }
+    if (*count > size0) {
+        OverlaybdSegment *first = &s->merged[size0];
+        OverlaybdSegment *last = &s->merged[*count - 1];
+
+        if (first->lba < begin0) {
+            uint64_t delta = begin0 - first->lba;
+
+            first->length -= delta;
+            first->lba = begin0;
+            if (!first->zeroed) {
+                first->moffset += delta;
+            }
+        }
+        if (last->lba + last->length > end) {
+            last->length = end - last->lba;
+        }
+    }
+    return true;
+}
+
+static int coroutine_fn GRAPH_RDLOCK
+overlaybd_co_preadv(BlockDriverState *bs, int64_t offset, int64_t bytes,
+                    QEMUIOVector *qiov, BdrvRequestFlags flags)
+{
+    BDRVOverlaybdState *s = bs->opaque;
+    uint64_t cur = offset >> BDRV_SECTOR_BITS;
+    uint64_t end = cur + (bytes >> BDRV_SECTOR_BITS);
+    size_t done = 0;
+    int ret = 0;
+
+    assert(QEMU_IS_ALIGNED(offset, BDRV_SECTOR_SIZE));
+    assert(QEMU_IS_ALIGNED(bytes, BDRV_SECTOR_SIZE));
+
+    while (cur < end) {
+        OverlaybdSegment *e;
+        OverlaybdLayer *l;
+        uint64_t run, run_end;
+        size_t idx = overlaybd_find_segment(s->merged, s->merged_count, cur);
+
+        if (idx == s->merged_count || s->merged[idx].lba >= end) {
+            qemu_iovec_memset(qiov, done, 0, (end - cur) * BDRV_SECTOR_SIZE);
+            break;
+        }
+        e = &s->merged[idx];
+        if (e->lba > cur) {
+            run = (e->lba - cur) * BDRV_SECTOR_SIZE;
+            qemu_iovec_memset(qiov, done, 0, run);
+            done += run;
+            cur = e->lba;
+        }
+        run_end = MIN(end, e->lba + e->length);
+        run = (run_end - cur) * BDRV_SECTOR_SIZE;
+        if (e->zeroed) {
+            qemu_iovec_memset(qiov, done, 0, run);
+        } else {
+            int64_t off = (e->moffset + (cur - e->lba)) * BDRV_SECTOR_SIZE;
+
+            l = &s->layers[e->layer];
+            if (l->is_zfile) {
+                ret = overlaybd_zfile_read(l, off, run, NULL, qiov, done);
+            } else {
+                ret = bdrv_co_preadv_part(l->child, l->base_offset + off, run,
+                                          qiov, done, 0);
+            }
+            if (ret < 0) {
+                return ret;
+            }
+        }
+        done += run;
+        cur = run_end;
+    }
+    return ret;
+}
+
+static void overlaybd_refresh_limits(BlockDriverState *bs, Error **errp)
+{
+    bs->bl.request_alignment = BDRV_SECTOR_SIZE;
+}
+
+static int overlaybd_probe(const uint8_t *buf, int buf_size,
+                           const char *filename)
+{
+    if (buf_size < 24) {
+        return 0;
+    }
+    /*
+     * Only a bare LSMT or ZFile magic is distinctive enough to claim a file.
+     * A tar-wrapped layer shows just its entry header here, the payload
+     * starting past the 512-byte probe window, and tar is far too generic a
+     * container to claim on that evidence -- every unrelated archive would
+     * then fail to open instead of being read as raw. So a tar-wrapped layer
+     * must be named explicitly (-f overlaybd, file.driver=,
+     * layers.<n>.driver= or a manifest), all of which bypass probing.
+     */
+    if (overlaybd_is_lsmt_header(buf) || overlaybd_is_zfile_header(buf)) {
+        return 100;
+    }
+    return 0;
+}
+
+static void overlaybd_close(BlockDriverState *bs)
+{
+    BDRVOverlaybdState *s = bs->opaque;
+    int i;
+
+    bdrv_graph_wrlock_drained();
+    for (i = 0; i < s->nb_layers; i++) {
+        if (s->layers[i].child) {
+            bdrv_unref_child(bs, s->layers[i].child);
+            s->layers[i].child = NULL;
+        }
+    }
+    bdrv_graph_wrunlock();
+
+    for (i = 0; i < s->nb_layers; i++) {
+        g_free(s->layers[i].zf.partial_offset);
+        g_free(s->layers[i].zf.deltas);
+        g_free(s->layers[i].index);
+    }
+    g_free(s->layers);
+    s->layers = NULL;
+    s->nb_layers = 0;
+    g_free(s->merged);
+    s->merged = NULL;
+    s->merged_count = 0;
+}
+
+/*
+ * Manifest handling: the layer stack comes either from an OCI image manifest
+ * (manifest option, or the file child content) or from an explicit
+ * bottom-first "layers" array. Of a manifest only "schemaVersion" (must be 2)
+ * and "layers" (non-empty, bottom-first) are read, and of each layer
+ * descriptor only "digest"; everything else -- the top-level "mediaType",
+ * "annotations", "subject" and the whole "config" descriptor -- is ignored,
+ * so the image config blob is never fetched.
+ *
+ * Blobs are named after the root they live under: ROOT/<alg>/<hex> for an OCI
+ * image layout's blobs/ or an explicit blob-path, or ROOT/<hex> when ROOT is
+ * itself an <alg> directory, i.e. the manifest is a sibling of the layers it
+ * names (an OCI layout read from inside blobs/sha256/, or a containerd
+ * content store).
+ *
+ * Specifications:
+ *   image manifest (schemaVersion, layers)
+ *     https://github.com/opencontainers/image-spec/blob/main/manifest.md
+ *   descriptor "digest" grammar, narrowed by overlaybd_parse_digest()
+ *     https://github.com/opencontainers/image-spec/blob/main/descriptor.md
+ *   the blobs/<alg>/<hex> directory layout
+ *     https://github.com/opencontainers/image-spec/blob/main/image-layout.md
+ */
+
+typedef enum {
+    OBD_BLOB_DIR,
+    OBD_BLOB_SIBLING,
+} OverlaybdBlobKind;
+
+typedef struct OverlaybdBlobRoot {
+    char *path;
+    OverlaybdBlobKind kind;
+    GPtrArray *blobs;
+} OverlaybdBlobRoot;
+
+#define OCI_DIGEST_ALG_MAX 32
+#define OCI_DIGEST_HEX_MAX 128
+/* an OCI image manifest is a few kilobytes; anything larger is not one */
+#define OCI_MANIFEST_MAX_SIZE (1u << 20)
+
+static char *overlaybd_blob_path(const OverlaybdBlobRoot *root,
+                                 const char *alg, const char *hex)
+{
+    switch (root->kind) {
+    case OBD_BLOB_SIBLING:
+        return g_strdup_printf("%s/%s", root->path, hex);
+    case OBD_BLOB_DIR:
+        return g_strdup_printf("%s/%s/%s", root->path, alg, hex);
+    }
+    g_assert_not_reached();
+}
+
+/*
+ * Validate a digest and split it into algorithm and hex parts. Both are used
+ * verbatim as path components, so anything outside the OCI grammar -- in
+ * particular "." and "/" -- is rejected. The grammar also allows separators
+ * in the algorithm and uppercase hex; both are excluded on purpose, since
+ * every tool in practice emits a lowercase "sha256" and narrowing the set
+ * costs nothing.
+ */
+static int overlaybd_parse_digest(const char *digest, char **alg, char **hex,
+                                  Error **errp)
+{
+    static const char alg_chars[] = "abcdefghijklmnopqrstuvwxyz0123456789";
+    static const char hex_chars[] = "0123456789abcdef";
+    const char *colon = strchr(digest, ':');
+    size_t alen, hlen;
+
+    if (!colon || strchr(colon + 1, ':')) {
+        goto invalid;
+    }
+    alen = colon - digest;
+    hlen = strlen(colon + 1);
+    if (alen < 1 || alen > OCI_DIGEST_ALG_MAX ||
+        hlen < 1 || hlen > OCI_DIGEST_HEX_MAX ||
+        strspn(digest, alg_chars) != alen ||
+        strspn(colon + 1, hex_chars) != hlen) {
+        goto invalid;
+    }
+
+    *alg = g_strndup(digest, alen);
+    *hex = g_strdup(colon + 1);
+    return 0;
+
+invalid:
+    error_setg(errp, "invalid layer digest '%s'", digest);
+    return -EINVAL;
+}
+
+static int overlaybd_reject_url(const char *what, const char *ref,
+                                Error **errp)
+{
+    if (path_has_protocol(ref)) {
+        error_setg(errp, "%s '%s' is a URL; only local files are supported",
+                   what, ref);
+        return -ENOTSUP;
+    }
+    return 0;
+}
+
+/*
+ * Put an explicit file child reference under @key, so that the blob is
+ * opened without format probing. A bare string reference would instead be
+ * looked up as a node name.
+ */
+static void overlaybd_put_child(QDict *options, const char *key,
+                                const char *value)
+{
+    /* two variables: reassigning a g_autofree one leaks the old value */
+    g_autofree char *drv_key = g_strdup_printf("%s.driver", key);
+    g_autofree char *ref = g_strdup_printf("%s.filename", key);
+
+    qdict_put_str(options, drv_key, "file");
+    qdict_put_str(options, ref, value);
+}
+
+static int overlaybd_blob_root_from_option(const char *blob_path,
+                                           OverlaybdBlobRoot *root,
+                                           Error **errp)
+{
+    char *end;
+    int ret;
+
+    ret = overlaybd_reject_url("blob-path", blob_path, errp);
+    if (ret < 0) {
+        return ret;
+    }
+
+    root->path = g_strdup(blob_path);
+    end = root->path + strlen(root->path);
+    while (end > root->path && end[-1] == '/') {
+        *--end = '\0';
+    }
+    if (!*root->path) {
+        error_setg(errp, "blob-path is empty");
+        g_free(root->path);
+        root->path = NULL;
+        return -EINVAL;
+    }
+    root->kind = OBD_BLOB_DIR;
+    return 0;
+}
+
+static int overlaybd_derive_blob_root(const char *manifest, const char *alg,
+                                      OverlaybdBlobRoot *root, Error **errp)
+{
+    g_autofree char *dir = NULL;
+    g_autofree char *base = NULL;
+    g_autofree char *alg_dir = NULL;
+
+    root->path = NULL;
+
+    dir = g_path_get_dirname(manifest);
+    base = g_path_get_basename(dir);
+    if (strcmp(base, alg) == 0) {
+        root->path = g_strdup(dir);
+        root->kind = OBD_BLOB_SIBLING;
+        return 0;
+    }
+
+    /*
+     * Otherwise the blobs are grouped by algorithm in a directory beside
+     * the manifest, either directly or under the blobs/ of an OCI image
+     * layout. The root is that directory's parent, since a directory root
+     * composes ROOT/<alg>/<hex>.
+     */
+    alg_dir = g_build_filename(dir, alg, NULL);
+    if (g_file_test(alg_dir, G_FILE_TEST_IS_DIR)) {
+        root->path = g_strdup(dir);
+    } else {
+        root->path = g_build_filename(dir, "blobs", NULL);
+        if (!g_file_test(root->path, G_FILE_TEST_IS_DIR)) {
+            error_setg(errp, "could not find the blobs of manifest '%s': no "
+                       "%s/ or blobs/ directory beside it; give blob-path",
+                       manifest, alg);
+            g_free(root->path);
+            root->path = NULL;
+            return -ENOENT;
+        }
+    }
+    root->kind = OBD_BLOB_DIR;
+    return 0;
+}
+
+static int overlaybd_parse_manifest(const char *content, QDict **manifest_out,
+                                    Error **errp)
+{
+    QDict *manifest;
+    QObject *obj;
+    int64_t version;
+
+    obj = qobject_from_json(content, errp);
+    if (!obj) {
+        return -EINVAL;
+    }
+    manifest = qobject_to(QDict, obj);
+    if (!manifest) {
+        error_setg(errp, "overlaybd manifest root is not a JSON object");
+        qobject_unref(obj);
+        return -EINVAL;
+    }
+
+    version = qdict_get_try_int(manifest, "schemaVersion", 0);
+    if (version != 2) {
+        error_setg(errp, "unsupported OCI schemaVersion %" PRId64 ", "
+                   "expected 2", version);
+        qobject_unref(obj);
+        return -ENOTSUP;
+    }
+    if (!qobject_to(QList, qdict_get(manifest, "layers"))) {
+        error_setg(errp, "overlaybd manifest has no \"layers\" array");
+        qobject_unref(obj);
+        return -EINVAL;
+    }
+    *manifest_out = manifest;
+    return 0;
+}
+
+static int overlaybd_paths_from_manifest(QDict *manifest, const char *location,
+                                         OverlaybdBlobRoot *root, Error **errp)
+{
+    QList *layers = qobject_to(QList, qdict_get(manifest, "layers"));
+    const QListEntry *entry;
+    int i = 0, ret;
+
+    for (entry = qlist_first(layers); entry; entry = qlist_next(entry)) {
+        QDict *desc = qobject_to(QDict, entry->value);
+        const char *digest = desc ? qdict_get_try_str(desc, "digest") : NULL;
+        g_autofree char *alg = NULL;
+        g_autofree char *hex = NULL;
+
+        if (!digest) {
+            error_setg(errp, "layers[%d] has no digest", i);
+            return -EINVAL;
+        }
+        ret = overlaybd_parse_digest(digest, &alg, &hex, errp);
+        if (ret < 0) {
+            return ret;
+        }
+        if (i == 0 && !root->path) {
+            ret = overlaybd_derive_blob_root(location, alg, root, errp);
+            if (ret < 0) {
+                return ret;
+            }
+        }
+        g_ptr_array_add(root->blobs, overlaybd_blob_path(root, alg, hex));
+        i++;
+    }
+    if (i == 0) {
+        error_setg(errp, "overlaybd manifest has no layers");
+        return -EINVAL;
+    }
+    return 0;
+}
+
+static int overlaybd_layers_from_options(QDict *options, int nb_layers,
+                                         Error **errp)
+{
+    int i;
+
+    for (i = 0; i < nb_layers; i++) {
+        char key[32];
+        const char *val;
+        int n, ret;
+
+        n = snprintf(key, sizeof(key), "layers.%d", i);
+        assert(n < (int)sizeof(key));
+
+        val = qdict_get_try_str(options, key);
+        if (val) {
+            g_autofree char *path = g_strdup(val);
+
+            ret = overlaybd_reject_url("layer", path, errp);
+            if (ret < 0) {
+                return ret;
+            }
+            qdict_del(options, key);
+            overlaybd_put_child(options, key, path);
+        }
+    }
+    return 0;
+}
+
+static int overlaybd_open_layers(BlockDriverState *bs, QDict *options,
+                                 const OverlaybdBlobRoot *root, int nb_layers,
+                                 BdrvChild *file_layer, Error **errp)
+{
+    BDRVOverlaybdState *s = bs->opaque;
+    int ret;
+    int i;
+
+    if (nb_layers > LSMT_MAX_LAYERS) {
+        error_setg(errp, "too many overlaybd layers (%d, maximum %d)",
+                   nb_layers, LSMT_MAX_LAYERS);
+        return -EINVAL;
+    }
+
+    s->layers = g_new0(OverlaybdLayer, nb_layers);
+    s->nb_layers = nb_layers;
+
+    if (file_layer) {
+        s->layers[0].child = file_layer;
+    } else {
+        for (i = 0; i < nb_layers; i++) {
+            BdrvChild *child;
+            char key[32];
+            int n;
+
+            if (root) {
+                n = snprintf(key, sizeof(key), "layer.%d", i);
+                assert(n < (int)sizeof(key));
+                overlaybd_put_child(options, key, root->blobs->pdata[i]);
+            } else {
+                n = snprintf(key, sizeof(key), "layers.%d", i);
+                assert(n < (int)sizeof(key));
+            }
+
+            child = bdrv_open_child(NULL, options, key, bs, &child_of_bds,
+                                    BDRV_CHILD_DATA | BDRV_CHILD_METADATA,
+                                    false, errp);
+            if (!child) {
+                ret = -EINVAL;
+                goto fail;
+            }
+            /*
+             * fill top-first: the last path (bottom-first) is the top
+             * layer
+             */
+            s->layers[nb_layers - 1 - i].child = child;
+        }
+    }
+
+    bdrv_graph_rdlock_main_loop();
+    for (i = 0; i < nb_layers; i++) {
+        ret = overlaybd_open_layer(&s->layers[i], errp);
+        if (ret < 0) {
+            goto unlock_fail;
+        }
+    }
+
+    s->merged_cap = 64;
+    s->merged = g_new(OverlaybdSegment, s->merged_cap);
+    if (!overlaybd_merge_level(s, 0, 0, UINT64_MAX, &s->merged_count)) {
+        error_setg(errp, "failed to merge overlaybd layer indexes");
+        ret = -EINVAL;
+        goto unlock_fail;
+    }
+
+    s->virtual_size = 0;
+    for (i = 0; i < nb_layers; i++) {
+        if (s->layers[i].virtual_size != 0) {
+            s->virtual_size = s->layers[i].virtual_size;
+            break;
+        }
+    }
+    if (s->virtual_size == 0) {
+        error_setg(errp, "overlaybd image has zero virtual size");
+        ret = -EINVAL;
+        goto unlock_fail;
+    }
+    /* DIV_ROUND_UP() below would wrap around to a zero-length image */
+    if (s->virtual_size > UINT64_MAX - (BDRV_SECTOR_SIZE - 1)) {
+        error_setg(errp, "overlaybd image virtual size %" PRIu64 " is too "
+                   "large", s->virtual_size);
+        ret = -EINVAL;
+        goto unlock_fail;
+    }
+
+    bs->total_sectors = DIV_ROUND_UP(s->virtual_size, BDRV_SECTOR_SIZE);
+    bdrv_graph_rdunlock_main_loop();
+    return 0;
+
+unlock_fail:
+    bdrv_graph_rdunlock_main_loop();
+fail:
+    return ret;
+}
+
+static int overlaybd_open(BlockDriverState *bs, QDict *options, int flags,
+                          Error **errp)
+{
+    g_autofree char *manifest_opt = NULL;
+    g_autofree char *blob_path = NULL;
+    OverlaybdBlobRoot root = { 0 };
+    const OverlaybdBlobRoot *blobs = NULL;
+    QDict *manifest = NULL;
+    const char *location = NULL;
+    char *content = NULL;
+    BdrvChild *file_layer = NULL;
+    bool use_file_child;
+    int nb_layers = 0;
+    int ret;
+
+    GLOBAL_STATE_CODE();
+
+    bdrv_graph_rdlock_main_loop();
+    ret = bdrv_apply_auto_read_only(bs, NULL, errp);
+    bdrv_graph_rdunlock_main_loop();
+    if (ret < 0) {
+        return ret;
+    }
+
+    qdict_flatten(options);
+
+    /* copy before deleting: qdict_get_try_str() borrows from the entry */
+    manifest_opt = g_strdup(qdict_get_try_str(options, "manifest"));
+    blob_path = g_strdup(qdict_get_try_str(options, "blob-path"));
+    qdict_del(options, "manifest");
+    qdict_del(options, "blob-path");
+    use_file_child = qdict_haskey(options, "file");
+    nb_layers = qdict_array_entries(options, "layers.");
+    if (nb_layers < 0) {
+        error_setg(errp, "option layers is not a valid array");
+        return -EINVAL;
+    }
+    if ((manifest_opt != NULL) + use_file_child + (nb_layers > 0) > 1) {
+        error_setg(errp, "only one of manifest, file or layers may be given");
+        return -EINVAL;
+    }
+    if (!manifest_opt && !use_file_child && nb_layers == 0) {
+        error_setg(errp, "either manifest, file or layers is required");
+        return -EINVAL;
+    }
+    if (blob_path && !manifest_opt && !use_file_child) {
+        error_setg(errp, "blob-path requires a manifest");
+        return -EINVAL;
+    }
+
+    if (manifest_opt) {
+        gsize len;
+
+        ret = overlaybd_reject_url("manifest", manifest_opt, errp);
+        if (ret < 0) {
+            return ret;
+        }
+        if (!g_file_get_contents(manifest_opt, &content, &len, NULL)) {
+            error_setg(errp, "could not read overlaybd manifest '%s'",
+                       manifest_opt);
+            return -ENOENT;
+        }
+        location = manifest_opt;
+    } else if (use_file_child) {
+        uint8_t sniff[512];
+        const uint8_t *p, *end;
+        int64_t len;
+
+        ret = bdrv_open_file_child(NULL, options, "file", bs, errp);
+        if (ret < 0) {
+            return ret;
+        }
+        /*
+         * The file child is either an OCI image manifest or a single layer,
+         * possibly inside a tar and/or ZFile shell. No layer magic starts
+         * with '{', so a JSON document is a manifest and anything else is a
+         * layer; overlaybd_open_layer() peels the shells and requires an
+         * LSMT core.
+         */
+        bdrv_graph_rdlock_main_loop();
+        len = bdrv_getlength(bs->file->bs);
+        if (len < 0) {
+            bdrv_graph_rdunlock_main_loop();
+            error_setg_errno(errp, -len, "could not get the file child size");
+            ret = len;
+            goto out;
+        }
+        memset(sniff, 0, sizeof(sniff));
+        ret = bdrv_pread(bs->file, 0, MIN((uint64_t)len, sizeof(sniff)),
+                         sniff, 0);
+        if (ret < 0) {
+            bdrv_graph_rdunlock_main_loop();
+            error_setg_errno(errp, -ret, "could not read the file child");
+            goto out;
+        }
+        location = bs->file->bs->filename;
+        end = sniff + MIN((uint64_t)len, sizeof(sniff));
+        for (p = sniff; p < end && g_ascii_isspace(*p); p++) {
+        }
+        if (p == end || *p != '{') {
+            file_layer = bs->file;
+            nb_layers = 1;
+            bdrv_graph_rdunlock_main_loop();
+        } else {
+            /*
+             * @len comes from the child rather than from overlaybd, so it
+             * cannot be handed to g_malloc() unchecked; a real manifest is
+             * a few kilobytes.
+             */
+            if (len > OCI_MANIFEST_MAX_SIZE) {
+                bdrv_graph_rdunlock_main_loop();
+                error_setg(errp, "file child starts like a JSON manifest but "
+                           "is %" PRId64 " bytes, above the %u byte limit",
+                           len, OCI_MANIFEST_MAX_SIZE);
+                ret = -EINVAL;
+                goto out;
+            }
+            content = g_malloc(len + 1);
+            ret = bdrv_pread(bs->file, 0, len, content, 0);
+            if (ret >= 0) {
+                content[len] = 0;
+                ret = 0;
+            } else {
+                error_setg_errno(errp, -ret,
+                                 "could not read overlaybd manifest");
+            }
+            bdrv_graph_rdunlock_main_loop();
+            if (ret < 0) {
+                goto out;
+            }
+        }
+    }
+
+    if (!file_layer) {
+        if (content) {
+            ret = overlaybd_parse_manifest(content, &manifest, errp);
+            if (ret < 0) {
+                goto out;
+            }
+            root.blobs = g_ptr_array_new_with_free_func(g_free);
+            if (blob_path) {
+                ret = overlaybd_blob_root_from_option(blob_path, &root, errp);
+                if (ret < 0) {
+                    goto out;
+                }
+            }
+            ret = overlaybd_paths_from_manifest(manifest, location, &root,
+                                                errp);
+            if (ret < 0) {
+                goto out;
+            }
+            nb_layers = root.blobs->len;
+            blobs = &root;
+        } else {
+            ret = overlaybd_layers_from_options(options, nb_layers, errp);
+            if (ret < 0) {
+                goto out;
+            }
+        }
+    }
+
+    ret = overlaybd_open_layers(bs, options, blobs, nb_layers, file_layer,
+                                errp);
+
+out:
+    g_free(root.path);
+    if (root.blobs) {
+        g_ptr_array_free(root.blobs, TRUE);
+    }
+    qobject_unref(manifest);
+    g_free(content);
+    if (ret < 0) {
+        overlaybd_close(bs);
+    }
+    return ret;
+}
+
+static BlockDriver bdrv_overlaybd = {
+    .format_name         = "overlaybd",
+    .instance_size       = sizeof(BDRVOverlaybdState),
+    .bdrv_probe          = overlaybd_probe,
+    .bdrv_open           = overlaybd_open,
+    .bdrv_child_perm     = bdrv_default_perms,
+    .bdrv_refresh_limits = overlaybd_refresh_limits,
+    .bdrv_co_preadv      = overlaybd_co_preadv,
+    .bdrv_close          = overlaybd_close,
+    .is_format           = true,
+};
+
+static void bdrv_overlaybd_init(void)
+{
+    bdrv_register(&bdrv_overlaybd);
+}
+
+block_init(bdrv_overlaybd_init);
diff --git a/docs/system/qemu-block-drivers.rst.inc b/docs/system/qemu-block-drivers.rst.inc
index 675daa72f9..2569f42e52 100644
--- a/docs/system/qemu-block-drivers.rst.inc
+++ b/docs/system/qemu-block-drivers.rst.inc
@@ -391,6 +391,43 @@ More disk image file formats are supported in a read-only mode.
 
   Apple disk image.
 
+.. program:: image-formats
+.. option:: overlaybd
+
+  Overlaybd image, the layered format used by containerd's overlaybd
+  snapshotter.  Each layer holds over-written data blocks, optionally
+  compressed block-wise with LZ4 or ZSTD and optionally wrapped in a
+  single-file tar.  Only sealed layers can be read; compressed layers
+  need QEMU built with lz4 / zstd support respectively.  See
+  https://github.com/containerd/overlaybd for the format specification.
+
+  The layer stack is given by exactly one of:
+
+  - ``manifest=<path>``: an OCI image manifest whose ``layers`` array
+    (bottom-first, of each entry only ``digest`` is used) names the layer
+    blobs, plus an optional ``blob-path=<root>`` saying where they are.
+    The root defaults to the blobs beside the manifest, which covers an
+    OCI image layout and a containerd content store.
+
+  - ``layers.<n>.driver=file,layers.<n>.filename=<path>``: an explicit
+    bottom-first list, the first element the base layer and the last the
+    top layer.
+
+  - a ``file`` child holding a manifest or a single sealed layer, as
+    ``-drive file=<path>`` provides.  A manifest must be named with
+    ``format=overlaybd``, since probing cannot recognise JSON.
+
+  A bare layer is detected by probing; a tar-wrapped one is not, as
+  probing sees only the tar entry header, so it must be named explicitly
+  via ``-f overlaybd``, a ``file`` child, ``layers.<n>`` or a manifest.
+  Tar-wrapped layers are usually shipped gzip-compressed and must be
+  unpacked first, since the driver reads an uncompressed tar.
+
+  For example::
+
+    qemu-img info --image-opts driver=overlaybd,manifest=manifest.json,\
+    blob-path=/var/lib/containerd/io.containerd.content.v1.content/blobs
+
 .. program:: image-formats
 .. option:: parallels
 
diff --git a/meson.build b/meson.build
index cfac634cf1..d7468db2a7 100644
--- a/meson.build
+++ b/meson.build
@@ -1391,6 +1391,12 @@ if not get_option('zstd').auto() or have_block
                     required: get_option('zstd'),
                     method: 'pkg-config')
 endif
+lz4 = not_found
+if not get_option('lz4').auto() or have_block
+  lz4 = dependency('liblz4', version: '>=1.8.0',
+                   required: get_option('lz4'),
+                   method: 'pkg-config')
+endif
 qpl = not_found
 if not get_option('qpl').auto() or have_system
   qpl = dependency('qpl', version: '>=1.5.0',
@@ -2542,6 +2548,7 @@ config_host_data.set('CONFIG_CRYPTO_SM3', crypto_sm3.found())
 config_host_data.set('CONFIG_HOGWEED', hogweed.found())
 config_host_data.set('CONFIG_MALLOC_TRIM', has_malloc_trim)
 config_host_data.set('CONFIG_ZSTD', zstd.found())
+config_host_data.set('CONFIG_LZ4', lz4.found())
 config_host_data.set('CONFIG_QPL', qpl.found())
 config_host_data.set('CONFIG_UADK', uadk.found())
 config_host_data.set('CONFIG_QATZIP', qatzip.found())
@@ -4856,6 +4863,7 @@ if have_block
   summary_info += {'bochs support':     get_option('bochs').allowed()}
   summary_info += {'cloop support':     get_option('cloop').allowed()}
   summary_info += {'dmg support':       get_option('dmg').allowed()}
+  summary_info += {'overlaybd support': get_option('overlaybd').allowed()}
   summary_info += {'qcow v1 support':   get_option('qcow1').allowed()}
   summary_info += {'vdi support':       get_option('vdi').allowed()}
   summary_info += {'vhdx support':      get_option('vhdx').allowed()}
@@ -4989,6 +4997,7 @@ summary_info += {'snappy support':    snappy}
 summary_info += {'bzip2 support':     libbzip2}
 summary_info += {'lzfse support':     liblzfse}
 summary_info += {'zstd support':      zstd}
+summary_info += {'lz4 support':       lz4}
 summary_info += {'Query Processing Library support': qpl}
 summary_info += {'UADK Library support': uadk}
 summary_info += {'qatzip support':    qatzip}
diff --git a/meson_options.txt b/meson_options.txt
index 6c2de6296f..a1b2ca6e56 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -270,6 +270,8 @@ option('xkbcommon', type : 'feature', value : 'auto',
        description: 'xkbcommon support')
 option('zstd', type : 'feature', value : 'auto',
        description: 'zstd compression support')
+option('lz4', type : 'feature', value : 'auto',
+       description: 'lz4 compression support')
 option('qpl', type : 'feature', value : 'auto',
        description: 'Query Processing Library support')
 option('uadk', type : 'feature', value : 'auto',
@@ -339,6 +341,8 @@ option('cloop', type: 'feature', value: 'auto',
        description: 'cloop image format support')
 option('dmg', type: 'feature', value: 'auto',
        description: 'dmg image format support')
+option('overlaybd', type: 'feature', value: 'auto',
+       description: 'overlaybd image format support')
 option('qcow1', type: 'feature', value: 'auto',
        description: 'qcow1 image format support')
 option('vdi', type: 'feature', value: 'auto',
diff --git a/qapi/block-core.json b/qapi/block-core.json
index aed2888147..18a3fe8a95 100644
--- a/qapi/block-core.json
+++ b/qapi/block-core.json
@@ -3390,6 +3390,7 @@
             'iscsi',
             'luks', 'nbd', 'nfs', 'null-aio', 'null-co', 'nvme',
             { 'name': 'nvme-io_uring', 'if': 'CONFIG_BLKIO' },
+            'overlaybd',
             'parallels', 'preallocate', 'qcow', 'qcow2', 'qed', 'quorum',
             'raw', 'rbd',
             { 'name': 'replication', 'if': 'CONFIG_REPLICATION' },
@@ -4182,6 +4183,43 @@
   'data': { 'path': 'str' },
   'if': 'CONFIG_BLKIO' }
 
+##
+# @BlockdevOptionsOverlaybd:
+#
+# Driver specific block device options for overlaybd (read-only).
+#
+# @manifest: an OCI image manifest naming the layer stack.  Its
+#     "layers" array is read bottom-first and only the "digest" of
+#     each entry is used; the image config blob is never fetched.
+#
+# @blob-path: directory the layer digests are resolved against.
+#     Optional, and derived from @manifest when absent.  Blobs are
+#     looked up as BLOB-PATH/<alg>/<hex>, i.e. the blobs/ of an OCI
+#     image layout.  A derived root is the blobs/ directory beside
+#     the manifest, except when the manifest is itself a blob, as in
+#     an OCI image layout addressed by digest or a containerd content
+#     store, in which case the layers are its siblings,
+#     BLOB-PATH/<hex>.
+#
+# @layers: explicit layer list, bottom-first: the first element is the
+#     base layer, the last element is the top layer (the same order as
+#     the "layers" array of an OCI image manifest)
+#
+# @file: the image to open, which may be either an OCI image manifest
+#     or a bare sealed layer file, i.e. a single-layer image
+#
+# Exactly one of @manifest, @layers and @file must be given, and
+# @blob-path only with a manifest.  Every reference is a local file;
+# a URL is rejected.
+#
+# Since: 11.2
+##
+{ 'struct': 'BlockdevOptionsOverlaybd',
+  'data': { '*manifest': 'str',
+            '*blob-path': 'str',
+            '*layers': [ 'BlockdevRef' ],
+            '*file': 'BlockdevRef' } }
+
 ##
 # @BlockdevOptionsVirtioBlkVfioPci:
 #
@@ -4905,6 +4943,7 @@
       'nvme':       'BlockdevOptionsNVMe',
       'nvme-io_uring': { 'type': 'BlockdevOptionsNvmeIoUring',
                          'if': 'CONFIG_BLKIO' },
+      'overlaybd':  'BlockdevOptionsOverlaybd',
       'parallels':  'BlockdevOptionsGenericFormat',
       'preallocate':'BlockdevOptionsPreallocate',
       'qcow2':      'BlockdevOptionsQcow2',
diff --git a/scripts/meson-buildoptions.sh b/scripts/meson-buildoptions.sh
index 08c9fbb62c..a7bfcce254 100644
--- a/scripts/meson-buildoptions.sh
+++ b/scripts/meson-buildoptions.sh
@@ -153,6 +153,7 @@ meson_options_help() {
   printf "%s\n" '  libvduse        build VDUSE Library'
   printf "%s\n" '  linux-aio       Linux AIO support'
   printf "%s\n" '  linux-io-uring  Linux io_uring support'
+  printf "%s\n" '  lz4             lz4 compression support'
   printf "%s\n" '  lzfse           lzfse support for DMG images'
   printf "%s\n" '  lzo             lzo compression support'
   printf "%s\n" '  malloc-trim     enable libc malloc_trim() for memory optimization'
@@ -168,6 +169,7 @@ meson_options_help() {
   printf "%s\n" '  nvmm            NVMM acceleration support'
   printf "%s\n" '  opengl          OpenGL support'
   printf "%s\n" '  oss             OSS sound support'
+  printf "%s\n" '  overlaybd       overlaybd image format support'
   printf "%s\n" '  pa              PulseAudio sound support'
   printf "%s\n" '  parallels       parallels image format support'
   printf "%s\n" '  passt           passt network backend support'
@@ -404,6 +406,8 @@ _meson_option_parse() {
     --disable-linux-io-uring) printf "%s" -Dlinux_io_uring=disabled ;;
     --localedir=*) quote_sh "-Dlocaledir=$2" ;;
     --localstatedir=*) quote_sh "-Dlocalstatedir=$2" ;;
+    --enable-lz4) printf "%s" -Dlz4=enabled ;;
+    --disable-lz4) printf "%s" -Dlz4=disabled ;;
     --enable-lzfse) printf "%s" -Dlzfse=enabled ;;
     --disable-lzfse) printf "%s" -Dlzfse=disabled ;;
     --enable-lzo) printf "%s" -Dlzo=enabled ;;
@@ -438,6 +442,8 @@ _meson_option_parse() {
     --disable-opengl) printf "%s" -Dopengl=disabled ;;
     --enable-oss) printf "%s" -Doss=enabled ;;
     --disable-oss) printf "%s" -Doss=disabled ;;
+    --enable-overlaybd) printf "%s" -Doverlaybd=enabled ;;
+    --disable-overlaybd) printf "%s" -Doverlaybd=disabled ;;
     --enable-pa) printf "%s" -Dpa=enabled ;;
     --disable-pa) printf "%s" -Dpa=disabled ;;
     --enable-parallels) printf "%s" -Dparallels=enabled ;;
diff --git a/tests/qemu-iotests/tests/overlaybd b/tests/qemu-iotests/tests/overlaybd
new file mode 100755
index 0000000000..7ce102426f
--- /dev/null
+++ b/tests/qemu-iotests/tests/overlaybd
@@ -0,0 +1,795 @@
+#!/usr/bin/env python3
+# group: rw quick
+#
+# Test the read-only overlaybd block driver
+#
+# Builds a 3-layer stack (plain sealed LSMT base, LZ4 zfile middle layer
+# with a zeroed range, ZSTD zfile top layer inside a tar wrapper with
+# dirty header fields) plus a standalone LZ4 zfile of 16-byte blocks
+# whose jump table exceeds 1 MiB, with pure-Python encoders -- LZ4
+# literal-only and match-based blocks and ZSTD Raw_Blocks are valid
+# without any compression library -- and verifies byte-exact reads
+# through all interface forms plus the error paths.
+#
+# Copyright (c) 2026 Huiba Li <huiba.lhb@alibaba-inc.com>
+#
+# SPDX-License-Identifier: GPL-2.0-or-later
+
+import hashlib
+import json
+import os
+import shutil
+import struct
+
+import iotests
+from iotests import file_path, log, qemu_img
+
+BLOCK = 4096
+NBLOCKS = 256  # 1 MiB virtual image
+SECTORS_PER_BLOCK = BLOCK // 512
+
+LSMT_MAGIC0 = b'LSMT\x00\x01\x02\x00'
+LSMT_MAGIC1 = bytes([
+    0x65, 0x7e, 0x63, 0xd2, 0x94, 0x44, 0x08, 0x4c,
+    0xa2, 0xd2, 0xc8, 0xec, 0x4f, 0xcf, 0xae, 0x8a
+])
+ZFILE_MAGIC0 = b'ZFile\x00\x01\x00'
+ZFILE_MAGIC1 = b'tuji.yyf@Alibaba'
+
+
+def _crc32c_table() -> list:
+    table = []
+    for i in range(256):
+        c = i
+        for _ in range(8):
+            c = (0x82f63b78 ^ (c >> 1)) if (c & 1) else (c >> 1)
+        table.append(c)
+    return table
+
+
+CRC32C_TABLE = _crc32c_table()
+
+# zfile.cpp's NOI_WELL_KNOWN_PRIME: per-block CRCs are computed with this
+# seed rather than 0
+CRC32C_SALT = 100007
+
+
+def crc32c(data: bytes, crc: int = 0) -> int:
+    """Raw CRC-32C accumulation -- no init inversion and no final xor.
+
+    This is overlaybd's crc32::crc32c_extend(), which is NOT the standard
+    init-0xFFFFFFFF/xorout-0xFFFFFFFF CRC-32C. The two disagree on every
+    input, and a fixture that used the standard form would agree with a
+    driver that made the same mistake, so both would look correct while
+    rejecting every real image.
+    """
+    table = CRC32C_TABLE
+    for b in data:
+        crc = table[(crc ^ b) & 0xff] ^ (crc >> 8)
+    return crc
+
+
+def lz4_block_literals(data: bytes) -> bytes:
+    out = bytearray()
+    n = len(data)
+    if n < 15:
+        out.append(n << 4)
+    else:
+        out.append(15 << 4)
+        rest = n - 15
+        while rest >= 255:
+            out.append(255)
+            rest -= 255
+        out.append(rest)
+    out += data
+    return bytes(out)
+
+
+def lz4_block_zeros(n: int) -> bytes:
+    """LZ4 block decoding to @n zero bytes.
+
+    One literal, one offset-1 match, then the five trailing literals the
+    format requires; a block that ends in a match is rejected by
+    LZ4_decompress_safe, so this only works for n >= 12.
+    """
+    match = n - 6
+    assert match >= 4
+    out = bytearray()
+    ml = match - 4
+    nib = min(ml, 15)
+    out.append((1 << 4) | nib)
+    out.append(0)
+    out += struct.pack('<H', 1)
+    ml -= nib
+    if nib == 15:
+        while ml >= 255:
+            out.append(255)
+            ml -= 255
+        out.append(ml)
+    out.append(5 << 4)
+    out += bytes(5)
+    return bytes(out)
+
+
+def zstd_frame_raw(data: bytes) -> bytes:
+    out = bytearray(b'\x28\xb5\x2f\xfd\xe0')
+    out += struct.pack('<Q', len(data))
+    off = 0
+    while True:
+        chunk = data[off:off + 128 * 1024]
+        last = 1 if off + len(chunk) >= len(data) else 0
+        out += struct.pack('<I', (len(chunk) << 3) | last)[:3]
+        out += chunk
+        off += len(chunk)
+        if last:
+            break
+    return bytes(out)
+
+
+def fill_base(b: int) -> bytes:
+    return bytes((b * 7 + 13 + i) & 0xff for i in range(BLOCK))
+
+
+def fill_mid(b: int) -> bytes:
+    return bytes((b * 11 + 29 + i * 3) & 0xff for i in range(BLOCK))
+
+
+def fill_top(b: int) -> bytes:
+    return bytes((b * 5 + 101 + i * 7) & 0xff for i in range(BLOCK))
+
+
+def lsmt_header(flags: int, index_off: int, index_size: int,
+                virtual_size: int) -> bytes:
+    ht = bytearray(4096)
+    ht[0:8] = LSMT_MAGIC0
+    ht[8:24] = LSMT_MAGIC1
+    struct.pack_into('<I', ht, 24, 390)
+    struct.pack_into('<I', ht, 28, flags)
+    struct.pack_into('<Q', ht, 32, index_off)
+    struct.pack_into('<Q', ht, 40, index_size)
+    struct.pack_into('<Q', ht, 48, virtual_size)
+    return bytes(ht)
+
+
+def build_lsmt(spec: dict, pattern) -> bytes:
+    """spec maps 4K block index -> 1 (data) or 2 (zeroed); missing = hole."""
+    out = bytearray(4096)
+    data = bytearray()
+    slot_of = {}
+    for b in sorted(k for k, v in spec.items() if v == 1):
+        slot_of[b] = (4096 >> 9) + len(data) // BLOCK * SECTORS_PER_BLOCK
+        data += pattern(b)
+    index_off = 4096 + len(data)
+
+    index = bytearray()
+    keys = sorted(spec.keys())
+    i = 0
+    while i < len(keys):
+        j = i
+        while j + 1 < len(keys) and keys[j + 1] == keys[j] + 1 \
+                and spec[keys[j + 1]] == spec[keys[i]]:
+            j += 1
+        first, count = keys[i], keys[j] - keys[i] + 1
+        assert count * SECTORS_PER_BLOCK <= (1 << 14) - 1
+        lba = first * SECTORS_PER_BLOCK
+        length = count * SECTORS_PER_BLOCK
+        if spec[keys[i]] == 2:
+            moffset, zeroed = 8, 1
+        else:
+            moffset, zeroed = slot_of[first], 0
+        lo = lba | (length << 50)
+        hi = moffset | (zeroed << 55)
+        index += struct.pack('<QQ', lo, hi)
+        i = j + 1
+
+    out += data
+    out += index
+
+    for flags in (2 | 4, 1 | 2):  # trailer first, then header
+        ht = lsmt_header(flags, index_off, len(index) // 16, NBLOCKS * BLOCK)
+        if flags & 1:  # header goes over the placeholder
+            out[0:4096] = ht
+        else:
+            out += ht
+    return bytes(out)
+
+
+def wrap_zfile(content: bytes, algo: int, garbage: bool = False,
+               block: int = BLOCK, overwrite: bool = False,
+               bad_block_crc: bool = False) -> bytes:
+    """Wrap into a ZFile blob: algo 1 = LZ4, 2 = ZSTD. With garbage, the
+    digest flag is clear and the digest/crc fields carry junk, like real
+    baselayer files. With overwrite, the header carries the final metadata
+    and the trailer is left out -- upstream's HEADER_OVERWRITE shape, which
+    is what zfile_compress produces when it rewrites the header in place.
+
+    With bad_block_crc the first block's trailing CRC is wrong while its
+    compressed bytes stay valid, so only the driver's per-block checksum
+    can notice; corrupting the compressed stream instead would be caught
+    by the decompressor and would prove nothing about the checksum.
+
+    All-zero blocks use the compact match encoding: the reference jump
+    table stores in-group offsets as uint16, so the compressed blocks of
+    a group must together stay under 64 KiB, which literal-only encoding
+    cannot manage at a small block size."""
+    zero_chunk = bytes(block)
+    zero_c = lz4_block_zeros(block) if algo == 1 else None
+    body = bytearray()
+    jump = bytearray()
+    for off in range(0, len(content), block):
+        chunk = content[off:off + block]
+        if algo == 1:
+            if chunk == zero_chunk:
+                cdata = zero_c
+            else:
+                cdata = lz4_block_literals(chunk)
+        else:
+            cdata = zstd_frame_raw(chunk)
+        body += cdata
+        crc = crc32c(cdata, CRC32C_SALT)
+        if bad_block_crc and off == 0:
+            crc ^= 0xffffffff
+        body += struct.pack('<I', crc)
+        jump += struct.pack('<I', len(cdata) + 4)
+
+    out = bytearray()
+    index_off = 512 + len(body)
+    for is_header in (True, False):
+        if overwrite and not is_header:
+            break  # the header already carries the final metadata
+        ht = bytearray(512)
+        ht[0:8] = ZFILE_MAGIC0
+        ht[8:24] = ZFILE_MAGIC1
+        struct.pack_into('<I', ht, 24, 96)
+        flags = (1 if is_header else 0) | 2 | 4 | (1 << 4)
+        if overwrite:
+            flags |= 1 << 3
+        struct.pack_into('<Q', ht, 32, flags)
+        struct.pack_into('<Q', ht, 40, index_off)
+        struct.pack_into('<Q', ht, 48, len(content) // block
+                         + (1 if len(content) % block else 0))
+        struct.pack_into('<Q', ht, 56, len(content))
+        struct.pack_into('<I', ht, 64, crc32c(bytes(jump)))
+        struct.pack_into('<I', ht, 72, block)
+        ht[76] = algo
+        ht[88] = 1
+        if garbage:
+            struct.pack_into('<Q', ht, 32,
+                             (1 if is_header else 0) | 2 | 4)
+            struct.pack_into('<I', ht, 28,
+                             0xfeedface if is_header else 0xdeadbeef)
+            struct.pack_into('<I', ht, 64, 0xdeadbeef)
+            ht[300:304] = b'\x01\x02\x03\x04'
+        else:
+            struct.pack_into('<I', ht, 28, crc32c(bytes(ht)))
+        out += ht
+        if is_header:
+            out += body
+            out += jump
+    return bytes(out)
+
+
+# tar's default blocking factor: 20 x 512-byte records
+TAR_BLOCKING = 10240
+
+
+def wrap_tar(content: bytes) -> bytes:
+    """Tar wrapper with the padding a real archiver writes, so that the
+    archive ends well past the layer and the driver has to locate the
+    layer trailer from the entry size rather than from the file size."""
+    hdr = bytearray(512)
+    hdr[0:9] = b'layer.bin'
+    hdr[156] = ord('0')
+    # slice assignment lengths must match exactly, or the bytearray
+    # silently shrinks: 11 octal digits into the 12-byte size field
+    # (the 12th byte stays NUL), 6 digits into the 8-byte checksum
+    hdr[124:135] = b'%011o' % len(content)
+    hdr[257:263] = b'ustar\x00'
+    hdr[263:265] = b'00'
+    hdr[148:156] = b' ' * 8
+    hdr[148:154] = b'%06o' % (sum(hdr) & 0o777777)
+    out = bytes(hdr) + content
+    out += bytes(-len(out) % 512)
+    out += bytes(1024)
+    out += bytes(-len(out) % TAR_BLOCKING)
+    return out
+
+
+# A ZFile of 16-byte blocks needs a jump table above 1 MiB (the point
+# where the driver switches to parallel loading) once the content passes
+# 4 MiB. 16 is the smallest block size whose all-zero LZ4 block still
+# fits the uint16 in-group deltas, so the data region is that big.
+BIG_BLOCK = 16
+BIG_DATA = 4259840
+BIG_VIRT_SECTORS = 24
+
+
+def fill_big(tag: int) -> bytes:
+    return bytes((tag * 31 + i * 5 + 7) & 0xff for i in range(4096))
+
+
+def build_big_lsmt() -> tuple:
+    """LSMT blob with two mapped 4K blocks, at the start and at the far
+    end of an otherwise zero data region, plus the expected image.
+
+    The two blocks are 4 MiB apart, so reading them back exercises jump
+    table lookups in two different groups.
+    """
+    index_off = 4096 + BIG_DATA
+    far_moff = (index_off - 4096) // 512
+    index = struct.pack('<QQ', 8 << 50, 8)
+    index += struct.pack('<QQ', 8 | (8 << 50), far_moff)
+
+    blob = bytearray(4096)
+    blob += fill_big(1)
+    blob += bytes(BIG_DATA - 2 * 4096)
+    blob += fill_big(2)
+    blob += index
+    assert len(blob) == index_off + len(index)
+
+    for flags in (2 | 4, 1 | 2):
+        ht = lsmt_header(flags, index_off, len(index) // 16,
+                         BIG_VIRT_SECTORS * 512)
+        if flags & 1:
+            blob[0:4096] = ht
+        else:
+            blob += ht
+
+    expected = fill_big(1) + fill_big(2) + bytes(4096)
+    return bytes(blob), expected
+
+
+def corrupt_jump_table(src: str, dst: str, span: int) -> None:
+    """Copy a ZFile with every jump table entry rewritten to @span, fixing
+    up the index CRC and both header/trailer digests so that only the
+    entry values differ."""
+    with open(src, 'rb') as f:
+        img = bytearray(f.read())
+
+    index_off = struct.unpack_from('<Q', img, 40)[0]
+    index_size = struct.unpack_from('<Q', img, 48)[0]
+    for i in range(index_size):
+        struct.pack_into('<I', img, index_off + i * 4, span)
+
+    crc = crc32c(bytes(img[index_off:index_off + index_size * 4]))
+    for ht_off in (0, len(img) - 512):
+        ht = img[ht_off:ht_off + 512]
+        struct.pack_into('<I', ht, 28, 0)
+        struct.pack_into('<I', ht, 64, crc)
+        struct.pack_into('<I', ht, 28, crc32c(bytes(ht)))
+        img[ht_off:ht_off + 512] = ht
+
+    with open(dst, 'wb') as f:
+        f.write(img)
+
+
+mid_img = file_path('mid_lz4.zfile')
+top_img = file_path('top_zstd.tar')
+top_plain_img = file_path('top_plain.lsmt')
+oci_dir = file_path('oci.d')
+oci_blobs = os.path.join(oci_dir, 'blobs')
+oci_exported = os.path.join(oci_dir, 'manifest.json')
+alg_dir = file_path('algdir.d')
+alg_manifest = os.path.join(alg_dir, 'manifest.json')
+probe_img = file_path('probe.zfile')
+converted = file_path('converted.raw')
+garbage_img = file_path('garbage.bin')
+garbage_zfile = file_path('garbage.zfile')
+big_img = file_path('big_index.zfile')
+small_img = file_path('small_lz4.zfile')
+overwrite_img = file_path('overwrite_lz4.zfile')
+huge_img = file_path('huge_vsize.lsmt')
+overrun_img = file_path('overrun_index.lsmt')
+corrupt_img = file_path('corrupt_index.zfile')
+badcrc_img = file_path('bad_block_crc.zfile')
+junk_tar = file_path('junk.tar')
+badver_json = file_path('bad_schema_version.json')
+nolayers_json = file_path('no_layers.json')
+traverse_json = file_path('digest_traversal.json')
+missing_json = file_path('missing_blob.json')
+
+TOP_BLOCKS = {b: 1 for b in list(range(0, 51)) + list(range(150, 201))}
+MID_BLOCKS = {b: 1 for b in range(60, 161)}
+MID_BLOCKS.update({b: 2 for b in range(210, 221)})
+
+
+def expected_block(b: int) -> bytes:
+    if b in TOP_BLOCKS:
+        return fill_top(b)
+    if b in MID_BLOCKS:
+        return fill_mid(b) if MID_BLOCKS[b] == 1 else bytes(BLOCK)
+    return fill_base(b)
+
+
+def expected_image() -> bytes:
+    return b''.join(expected_block(b) for b in range(NBLOCKS))
+
+
+def top_only_image() -> bytes:
+    return b''.join(fill_top(b) if b in TOP_BLOCKS else bytes(BLOCK)
+                    for b in range(NBLOCKS))
+
+
+OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json'
+OCI_LAYER_MEDIA_TYPE = 'application/vnd.oci.image.layer.v1.tar'
+OCI_EMPTY_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json'
+
+
+def write_blob(data: bytes) -> str:
+    digest = 'sha256:' + hashlib.sha256(data).hexdigest()
+    alg, hexsum = digest.split(':', 1)
+    d = os.path.join(oci_blobs, alg)
+    os.makedirs(d, exist_ok=True)
+    with open(os.path.join(d, hexsum), 'wb') as f:
+        f.write(data)
+    return digest
+
+
+def digest_path(digest: str) -> str:
+    alg, hexsum = digest.split(':', 1)
+    return os.path.join(oci_blobs, alg, hexsum)
+
+
+def build_oci_manifest(layers: list) -> tuple:
+    """Write @layers (bottom-first) as blobs plus a manifest listing them.
+    Returns the manifest blob's path and the layer blob paths.
+
+    The image config blob is written as well, since the driver has to
+    ignore it, but index.json is left out: the tests name the manifest
+    directly instead of resolving it through the index."""
+    cfg = b'{}'
+    digests = [write_blob(data) for data in layers]
+    manifest = json.dumps({
+        'schemaVersion': 2,
+        'mediaType': OCI_MANIFEST_MEDIA_TYPE,
+        'config': {'mediaType': OCI_EMPTY_MEDIA_TYPE,
+                   'digest': write_blob(cfg), 'size': len(cfg)},
+        'layers': [{'mediaType': OCI_LAYER_MEDIA_TYPE,
+                    'digest': d, 'size': len(data)}
+                   for d, data in zip(digests, layers)],
+        'annotations': {'org.opencontainers.image.ref.name': 'latest'},
+    }).encode()
+    return digest_path(write_blob(manifest)), [digest_path(d) for d in digests]
+
+
+def mutated_manifest(src: str, dst: str, mutate) -> str:
+    with open(src) as f:
+        m = json.load(f)
+    mutate(m)
+    with open(dst, 'w') as f:
+        json.dump(m, f)
+    return dst
+
+
+def huge_virtual_size(src: str, dst: str) -> None:
+    """Copy an LSMT with virtual_size set to the value that makes the
+    driver's sectors conversion wrap around to a zero-length image. An
+    LSMT header carries no digest, so both copies can be patched raw."""
+    with open(src, 'rb') as f:
+        img = bytearray(f.read())
+    for off in (48, len(img) - 4096 + 48):
+        struct.pack_into('<Q', img, off, (1 << 64) - 1)
+    with open(dst, 'wb') as f:
+        f.write(img)
+
+
+def overrun_lsmt_index(src: str, dst: str, pad: int = 256) -> None:
+    """Copy a bare LSMT with @pad bytes of trailing junk after its trailer,
+    whose trailer then declares index entries reaching into that junk.
+
+    The padding is what makes the case reachable: the driver's view of the
+    layer ends at the child's length, so a bound taken at view_size - 4096
+    accepts the overrun, while the trailer position the backward scan
+    actually found is @pad bytes earlier. @pad must stay under 512 or the
+    scan window no longer reaches the trailer. An LSMT header carries no
+    digest, so the copy can be patched raw."""
+    assert pad < 512 and pad % 16 == 0
+    with open(src, 'rb') as f:
+        img = bytearray(f.read())
+    for off in (40, len(img) - 4096 + 40):
+        n = struct.unpack_from('<Q', img, off)[0]
+        struct.pack_into('<Q', img, off, n + pad // 16)
+    img += bytes(pad)
+    with open(dst, 'wb') as f:
+        f.write(img)
+
+
+def make_fixtures() -> tuple:
+    base = build_lsmt({b: 1 for b in range(NBLOCKS)}, fill_base)
+    mid = wrap_zfile(build_lsmt(MID_BLOCKS, fill_mid), algo=1)
+    top = wrap_tar(wrap_zfile(build_lsmt(TOP_BLOCKS, fill_top),
+                              algo=2, garbage=True))
+
+    with open(mid_img, 'wb') as f:
+        f.write(mid)
+    with open(top_img, 'wb') as f:
+        f.write(top)
+    with open(top_plain_img, 'wb') as f:
+        f.write(build_lsmt(TOP_BLOCKS, fill_top))
+    with open(garbage_img, 'wb') as f:
+        f.write(os.urandom(32 * 1024))
+    with open(garbage_zfile, 'wb') as f:
+        f.write(wrap_zfile(os.urandom(64 * 1024), algo=1))
+    with open(junk_tar, 'wb') as f:
+        f.write(wrap_tar(os.urandom(64 * 1024)))
+    with open(small_img, 'wb') as f:
+        f.write(wrap_zfile(build_lsmt({0: 1}, fill_base), algo=1))
+    with open(overwrite_img, 'wb') as f:
+        f.write(wrap_zfile(build_lsmt(TOP_BLOCKS, fill_top), algo=1,
+                           overwrite=True))
+    huge_virtual_size(top_plain_img, huge_img)
+    overrun_lsmt_index(top_plain_img, overrun_img)
+
+    os.mkdir(oci_dir)
+    manifest, blobs = build_oci_manifest([base, mid, top])
+    shutil.copyfile(manifest, oci_exported)
+
+    os.mkdir(alg_dir)
+    os.mkdir(os.path.join(alg_dir, 'sha256'))
+    for blob in blobs:
+        shutil.copyfile(blob, os.path.join(alg_dir, 'sha256',
+                                           os.path.basename(blob)))
+    shutil.copyfile(manifest, alg_manifest)
+
+    big_blob, big_expected = build_big_lsmt()
+    with open(big_img, 'wb') as f:
+        f.write(wrap_zfile(big_blob, algo=1, block=BIG_BLOCK))
+    return big_expected, manifest, blobs
+
+
+def codec_supported(algo: int) -> bool:
+    """
+    True unless the driver says this codec was not compiled in.
+
+    Keyed on the driver's own message, not on the exit status: a probe
+    that only checked the status would also report "not compiled in"
+    when the read path is merely broken, turning a real failure into a
+    silent not-run.
+    """
+    name = {1: 'lz4', 2: 'zstd'}[algo]
+    with open(probe_img, 'wb') as f:
+        f.write(wrap_zfile(build_lsmt({0: 1}, fill_base), algo=algo))
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,layers.0.driver=file,'
+                   f'layers.0.filename={probe_img}', check=False)
+    return f'without {name} support' not in res.stdout
+
+
+def convert(opts: str, out: str):
+    return qemu_img('convert', '--image-opts', '-O', 'raw', opts, out,
+                    check=False)
+
+
+for algo in (1, 2):
+    if not codec_supported(algo):
+        iotests.notrun(f'zfile algo {algo} not compiled in')
+
+
+def read_converted(path: str) -> bytes:
+    with open(path, 'rb') as f:
+        return f.read()
+
+
+def probed_format(path: str) -> str:
+    res = qemu_img('info', path, check=False)
+    for line in res.stdout.splitlines():
+        if line.startswith('file format:'):
+            return line.split(':', 1)[1].strip()
+    return ''
+
+
+def expect(cond: bool, msg: str) -> None:
+    if not cond:
+        raise AssertionError(msg)
+
+
+def test_overlaybd() -> None:
+    big_expected, manifest, blobs = make_fixtures()
+    # the manifest is itself a blob in blobs/sha256/, so the blob root is
+    # derived from its own path -- the OCI image layout and containerd
+    # content store shapes
+    cfg = f'driver=overlaybd,manifest={manifest}'
+
+    res = qemu_img('info', '--image-opts', cfg)
+    expect('virtual size: 1 MiB' in res.stdout, 'unexpected info output')
+
+    ret = convert(cfg, converted)
+    expect(ret.returncode == 0, ret.stderr or 'convert failed')
+    expect(read_converted(converted) == expected_image(),
+           'manifest mode: content mismatch')
+    log('manifest mode: byte-exact')
+
+    for name, opts in (
+            ('explicit blob-path',
+             f'driver=overlaybd,manifest={manifest},blob-path={oci_blobs}'),
+            ('exported manifest',
+             f'driver=overlaybd,manifest={oci_exported},'
+             f'blob-path={oci_blobs}'),
+            ('exported manifest, derived blob-path',
+             f'driver=overlaybd,manifest={oci_exported}'),
+            ('manifest beside an <alg> directory',
+             f'driver=overlaybd,manifest={alg_manifest}')):
+        ret = convert(opts, converted)
+        expect(ret.returncode == 0, ret.stderr or f'{name}: convert failed')
+        expect(read_converted(converted) == expected_image(),
+               f'{name}: content mismatch')
+    log('blob-path forms: byte-exact')
+
+    layers = (f'driver=overlaybd,'
+              f'layers.0.driver=file,layers.0.filename={blobs[0]},'
+              f'layers.1.driver=file,layers.1.filename={blobs[1]},'
+              f'layers.2.driver=file,layers.2.filename={blobs[2]}')
+    ret = convert(layers, converted)
+    expect(ret.returncode == 0, ret.stderr or 'convert failed')
+    expect(read_converted(converted) == expected_image(),
+           'layers mode: content mismatch')
+    log('layers mode: byte-exact')
+
+    single = f'driver=overlaybd,layers.0.driver=file,' \
+             f'layers.0.filename={top_plain_img}'
+    ret = convert(single, converted)
+    expect(ret.returncode == 0, ret.stderr or 'convert failed')
+    expect(read_converted(converted) == top_only_image(),
+           'single layer: content mismatch')
+    log('single plain layer: byte-exact')
+
+    single = f'driver=overlaybd,layers.0.driver=file,' \
+             f'layers.0.filename={overwrite_img}'
+    ret = convert(single, converted)
+    expect(ret.returncode == 0, ret.stderr or 'convert failed')
+    expect(read_converted(converted) == top_only_image(),
+           'overwrite header: content mismatch')
+    log('header-overwrite zfile: byte-exact')
+
+    # the file child given as a dict reference carries the manifest
+    # (a bare string "file" would be a node-name reference, which is
+    # why -drive file=... passes the filename as a parameter); the blob
+    # root has to be derived from the child's own filename
+    ret = convert(f'driver=overlaybd,file.driver=file,'
+                  f'file.filename={manifest}', converted)
+    expect(ret.returncode == 0, ret.stderr or 'convert failed')
+    expect(read_converted(converted) == expected_image(),
+           'file-manifest mode: content mismatch')
+    log('file child as manifest: byte-exact')
+
+    ret = convert(f'driver=overlaybd,file.driver=file,'
+                  f'file.filename={top_img}', converted)
+    expect(ret.returncode == 0, ret.stderr or 'convert failed')
+    expect(read_converted(converted) == top_only_image(),
+           'file layer mode: content mismatch')
+    log('file child as tar layer: byte-exact')
+
+    # probing sees only the first 512 bytes: a bare LSMT and a bare ZFile
+    # match on their magic, but a tar shell shows nothing there except a
+    # generic entry header, so tars are deliberately not claimed -- every
+    # unrelated tar would then fail to open, and probing has no fallback
+    expect(probed_format(top_plain_img) == 'overlaybd', 'LSMT not probed')
+    expect(probed_format(mid_img) == 'overlaybd', 'ZFile not probed')
+    expect(probed_format(top_img) == 'raw', 'tar layer claimed by probing')
+    expect(probed_format(garbage_img) == 'raw', 'garbage not probed as raw')
+    log('format probing verified')
+
+    big = f'driver=overlaybd,layers.0.driver=file,' \
+          f'layers.0.filename={big_img}'
+    res = qemu_img('info', '--image-opts', big)
+    expect('virtual size: 12 KiB' in res.stdout, 'unexpected info output')
+    ret = convert(big, converted)
+    expect(ret.returncode == 0, ret.stderr or 'convert failed')
+    expect(read_converted(converted) == big_expected,
+           'big jump table: content mismatch')
+    log('1 MiB jump table: byte-exact')
+
+    # error paths (qemu_img() merges stdout and stderr)
+    res = qemu_img('info', '--image-opts', 'driver=overlaybd', check=False)
+    expect('either manifest, file or layers is required' in res.stdout,
+           'missing options: wrong error')
+    res = qemu_img('info', '--image-opts',
+                   'driver=overlaybd,manifest=/nonexistent.json', check=False)
+    expect('could not read overlaybd manifest' in res.stdout,
+           'missing manifest: wrong error')
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,manifest={manifest},'
+                   f'layers.0.driver=file,'
+                   f'layers.0.filename={mid_img}', check=False)
+    expect('only one of manifest, file or layers may be given' in res.stdout,
+           'conflicting options: wrong error')
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,blob-path={oci_blobs},'
+                   f'layers.0.driver=file,'
+                   f'layers.0.filename={mid_img}', check=False)
+    expect('blob-path requires a manifest' in res.stdout,
+           'blob-path without manifest: wrong error')
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,manifest={manifest},blob-path=/',
+                   check=False)
+    expect('blob-path is empty' in res.stdout,
+           'empty blob-path: wrong error')
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,layers.0.driver=file,'
+                   f'layers.0.filename={garbage_img}', check=False)
+    expect('invalid LSMT header' in res.stdout,
+           'garbage layer: wrong error')
+    res = qemu_img('info', '-f', 'overlaybd', junk_tar, check=False)
+    expect('invalid LSMT header' in res.stdout,
+           'tar of garbage: wrong error')
+    # probing cannot see past a shell: it claims this on the ZFile magic,
+    # so the payload is only verified once the ZFile is open
+    res = qemu_img('info', garbage_zfile, check=False)
+    expect('invalid LSMT header' in res.stdout,
+           'ZFile of garbage: wrong error')
+
+    mutated_manifest(manifest, badver_json,
+                     lambda m: m.update(schemaVersion=1))
+    mutated_manifest(manifest, nolayers_json, lambda m: m.pop('layers'))
+    mutated_manifest(manifest, traverse_json,
+                     lambda m: m['layers'][0].update(
+                         digest='sha256:../../../../../../../etc/passwd'))
+    mutated_manifest(manifest, missing_json,
+                     lambda m: m['layers'][0].update(
+                         digest='sha256:' + '0' * 64))
+    for name, path, want in (
+            ('schemaVersion', badver_json, 'unsupported OCI schemaVersion'),
+            ('no layers', nolayers_json, 'has no "layers" array'),
+            ('digest traversal', traverse_json, 'invalid layer digest')):
+        res = qemu_img('info', '--image-opts',
+                       f'driver=overlaybd,manifest={path},'
+                       f'blob-path={oci_blobs}', check=False)
+        expect(want in res.stdout, f'{name}: wrong error: {res.stdout}')
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,manifest={missing_json},'
+                   f'blob-path={oci_blobs}', check=False)
+    expect(f'{oci_blobs}/sha256/' + '0' * 64 in res.stdout,
+           f'missing blob: wrong error: {res.stdout}')
+
+    for name, opts in (
+            ('manifest URL',
+             'driver=overlaybd,manifest=http://127.0.0.1:1/manifest.json'),
+            ('blob-path URL',
+             f'driver=overlaybd,manifest={manifest},'
+             'blob-path=http://127.0.0.1:1/blobs'),
+            ('layer URL',
+             'driver=overlaybd,layers.0=http://127.0.0.1:1/blobs/sha256/0')):
+        res = qemu_img('info', '--image-opts', opts, check=False)
+        expect('only local files are supported' in res.stdout,
+               f'{name}: wrong error: {res.stdout[-200:]}')
+
+    # 30000-byte blocks: the running in-group offset no longer fits u16
+    corrupt_jump_table(small_img, corrupt_img, 30000)
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,layers.0.driver=file,'
+                   f'layers.0.filename={corrupt_img}', check=False)
+    expect('jump table group exceed' in res.stdout,
+           'oversized jump table group: wrong error')
+    # A valid compressed block whose trailing CRC is wrong. Opening the LSMT
+    # layer reads its header, which is ZFile block 0, so only the driver's
+    # per-block checksum can turn this into an error; without it the block
+    # decodes fine and the corruption is never noticed.
+    with open(badcrc_img, 'wb') as f:
+        f.write(wrap_zfile(build_lsmt({0: 1}, fill_base), algo=1,
+                           bad_block_crc=True))
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,layers.0.driver=file,'
+                   f'layers.0.filename={badcrc_img}', check=False)
+    expect(res.returncode != 0 and 'Input/output error' in res.stdout,
+           f'corrupt block CRC: rc={res.returncode} {res.stdout[-200:]}')
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,layers.0.driver=file,'
+                   f'layers.0.filename={huge_img}', check=False)
+    expect('virtual size 18446744073709551615 is too large' in res.stdout,
+           f'huge virtual size: wrong error: {res.stdout}')
+    res = qemu_img('info', '--image-opts',
+                   f'driver=overlaybd,layers.0.driver=file,'
+                   f'layers.0.filename={overrun_img}', check=False)
+    expect('invalid LSMT index location' in res.stdout,
+           f'overrunning LSMT index: wrong error: {res.stdout[-200:]}')
+    log('error paths verified')
+
+    # file_path() only removes plain files, so the layout directories go
+    # explicitly (as tests/vvfat does)
+    shutil.rmtree(oci_dir)
+    shutil.rmtree(alg_dir)
+
+
+if __name__ == '__main__':
+    iotests.script_main(test_overlaybd, supported_fmts=['raw'],
+                        supported_protocols=['file'])
diff --git a/tests/qemu-iotests/tests/overlaybd.out b/tests/qemu-iotests/tests/overlaybd.out
new file mode 100644
index 0000000000..be1352fbce
--- /dev/null
+++ b/tests/qemu-iotests/tests/overlaybd.out
@@ -0,0 +1,10 @@
+manifest mode: byte-exact
+blob-path forms: byte-exact
+layers mode: byte-exact
+single plain layer: byte-exact
+header-overwrite zfile: byte-exact
+file child as manifest: byte-exact
+file child as tar layer: byte-exact
+format probing verified
+1 MiB jump table: byte-exact
+error paths verified
-- 
2.54.0 (Apple Git-157)