From a3df7db43b662e2766c2d41f3921fa5cbc6a425c Mon Sep 17 00:00:00 2001 From: Schamper <1254028+Schamper@users.noreply.github.com> Date: Wed, 26 Nov 2025 11:34:28 +0100 Subject: [PATCH 1/2] Replace zstd with stdlib and backports zstd --- dissect/hypervisor/disk/qcow2.py | 18 ++++++------------ pyproject.toml | 1 + tests/_data/disk/qcow2/basic-zstd.qcow2.gz | Bin 0 -> 11381 bytes tests/conftest.py | 5 +++++ tests/disk/test_qcow2.py | 5 +++-- 5 files changed, 15 insertions(+), 14 deletions(-) create mode 100644 tests/_data/disk/qcow2/basic-zstd.qcow2.gz diff --git a/dissect/hypervisor/disk/qcow2.py b/dissect/hypervisor/disk/qcow2.py index e869f71..afeba96 100644 --- a/dissect/hypervisor/disk/qcow2.py +++ b/dissect/hypervisor/disk/qcow2.py @@ -3,9 +3,9 @@ # - https://github.com/qemu/qemu/blob/master/docs/interop/qcow2.txt from __future__ import annotations +import sys import zlib from functools import cached_property, lru_cache -from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, BinaryIO @@ -28,8 +28,10 @@ from collections.abc import Iterator try: - import zstandard as zstd - + if sys.version_info >= (3, 14): + from compression import zstd + else: + from backports import zstd HAS_ZSTD = True except ImportError: HAS_ZSTD = False @@ -384,16 +386,8 @@ def _decompress(self, buf: bytes) -> bytes: return dctx.decompress(buf, self.qcow2.cluster_size) if self.qcow2.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZSTD: - result = [] - dctx = zstd.ZstdDecompressor() - reader = dctx.stream_reader(BytesIO(buf)) - while reader.tell() < self.qcow2.cluster_size: - chunk = reader.read(self.qcow2.cluster_size - reader.tell()) - if not chunk: - break - result.append(chunk) - return b"".join(result) + return dctx.decompress(buf, self.qcow2.cluster_size) raise Error(f"Invalid compression type: {self.qcow2.compression_type}") diff --git a/pyproject.toml b/pyproject.toml index 8404869..3ea276d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ repository = "https://github.com/fox-it/dissect.hypervisor" [project.optional-dependencies] full = [ "pycryptodome", + "backports.zstd; python_version < '3.14'", ] dev = [ "dissect.hypervisor[full]", diff --git a/tests/_data/disk/qcow2/basic-zstd.qcow2.gz b/tests/_data/disk/qcow2/basic-zstd.qcow2.gz new file mode 100644 index 0000000000000000000000000000000000000000..33d3ccae6e9ed00a496c6dd114f7bdba241d693e GIT binary patch literal 11381 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 Y{SN`>mMuqxK)1;k`uFQLt7o_W1-@i9VE_OC literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py index 9bb7818..3673181 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -89,6 +89,11 @@ def basic_qcow2() -> Iterator[BinaryIO]: yield from open_file_gz("_data/disk/qcow2/basic.qcow2.gz") +@pytest.fixture +def basic_zstd_qcow2() -> Iterator[BinaryIO]: + yield from open_file_gz("_data/disk/qcow2/basic-zstd.qcow2.gz") + + @pytest.fixture def data_file_qcow2() -> Path: return absolute_path("_data/disk/qcow2/data-file.qcow2.gz") diff --git a/tests/disk/test_qcow2.py b/tests/disk/test_qcow2.py index e7d6e42..017a919 100644 --- a/tests/disk/test_qcow2.py +++ b/tests/disk/test_qcow2.py @@ -16,8 +16,9 @@ def mock_open_gz(self: Path, *args, **kwargs) -> BinaryIO: return gzip.open(self if self.suffix.lower() == ".gz" else self.with_suffix(self.suffix + ".gz")) -def test_basic(basic_qcow2: BinaryIO) -> None: - qcow2 = QCow2(basic_qcow2) +@pytest.mark.parametrize("name", ["basic_qcow2", "basic_zstd_qcow2"]) +def test_basic(name: str, request: pytest.FixtureRequest) -> None: + qcow2 = QCow2(request.getfixturevalue(name)) assert qcow2.backing_file is None assert qcow2.data_file is qcow2.fh From acb23b86c5ca77b76e43581b19c3dfe180669f6f Mon Sep 17 00:00:00 2001 From: Erik Schamper <1254028+Schamper@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:09:34 +0100 Subject: [PATCH 2/2] Update qcow2.py Co-authored-by: Yun Zheng Hu --- dissect/hypervisor/disk/qcow2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dissect/hypervisor/disk/qcow2.py b/dissect/hypervisor/disk/qcow2.py index afeba96..a81ecfb 100644 --- a/dissect/hypervisor/disk/qcow2.py +++ b/dissect/hypervisor/disk/qcow2.py @@ -29,7 +29,7 @@ try: if sys.version_info >= (3, 14): - from compression import zstd + from compression import zstd # novermin else: from backports import zstd HAS_ZSTD = True