dissect.hypervisor 3.18.dev1__py3-none-any.whl → 3.19.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -171,9 +171,6 @@ UNALLOCATED_SUBCLUSTER_TYPES = (
171
171
  )
172
172
 
173
173
 
174
- def ctz(value: int, size: int = 32) -> int:
174
+ def ctz(value: int) -> int:
175
175
  """Count the number of zero bits in an integer of a given size."""
176
- for i in range(size):
177
- if value & (1 << i):
178
- return i
179
- return 0
176
+ return (value & -value).bit_length() - 1 if value else 0
@@ -0,0 +1,190 @@
1
+ # Generated by cstruct-stubgen
2
+ from typing import BinaryIO, Literal, overload
3
+
4
+ import dissect.cstruct as __cs__
5
+ from typing_extensions import TypeAlias
6
+
7
+ class _c_qcow2(__cs__.cstruct):
8
+ MIN_CLUSTER_BITS: Literal[9] = ...
9
+ MAX_CLUSTER_BITS: Literal[21] = ...
10
+ QCOW2_COMPRESSED_SECTOR_SIZE: Literal[512] = ...
11
+ QCOW2_COMPRESSION_TYPE_ZLIB: Literal[0] = ...
12
+ QCOW2_COMPRESSION_TYPE_ZSTD: Literal[1] = ...
13
+ L1E_SIZE: Literal[8] = ...
14
+ L2E_SIZE_NORMAL: Literal[8] = ...
15
+ L2E_SIZE_EXTENDED: Literal[16] = ...
16
+ L1E_OFFSET_MASK: Literal[72057594037927424] = ...
17
+ L2E_OFFSET_MASK: Literal[72057594037927424] = ...
18
+ L2E_COMPRESSED_OFFSET_SIZE_MASK: Literal[4611686018427387903] = ...
19
+ QCOW_OFLAG_COPIED: Literal[9223372036854775808] = ...
20
+ QCOW_OFLAG_COMPRESSED: Literal[4611686018427387904] = ...
21
+ QCOW_OFLAG_ZERO: Literal[1] = ...
22
+ QCOW_EXTL2_SUBCLUSTERS_PER_CLUSTER: Literal[32] = ...
23
+ QCOW2_INCOMPAT_DIRTY_BITNR: Literal[0] = ...
24
+ QCOW2_INCOMPAT_CORRUPT_BITNR: Literal[1] = ...
25
+ QCOW2_INCOMPAT_DATA_FILE_BITNR: Literal[2] = ...
26
+ QCOW2_INCOMPAT_COMPRESSION_BITNR: Literal[3] = ...
27
+ QCOW2_INCOMPAT_EXTL2_BITNR: Literal[4] = ...
28
+ QCOW2_INCOMPAT_DIRTY: Literal[1] = ...
29
+ QCOW2_INCOMPAT_CORRUPT: Literal[2] = ...
30
+ QCOW2_INCOMPAT_DATA_FILE: Literal[4] = ...
31
+ QCOW2_INCOMPAT_COMPRESSION: Literal[8] = ...
32
+ QCOW2_INCOMPAT_EXTL2: Literal[16] = ...
33
+ QCOW2_EXT_MAGIC_END: Literal[0] = ...
34
+ QCOW2_EXT_MAGIC_BACKING_FORMAT: Literal[3799591626] = ...
35
+ QCOW2_EXT_MAGIC_FEATURE_TABLE: Literal[1745090647] = ...
36
+ QCOW2_EXT_MAGIC_CRYPTO_HEADER: Literal[87539319] = ...
37
+ QCOW2_EXT_MAGIC_BITMAPS: Literal[595929205] = ...
38
+ QCOW2_EXT_MAGIC_DATA_FILE: Literal[1145132097] = ...
39
+ class QCowHeader(__cs__.Structure):
40
+ magic: _c_qcow2.uint32
41
+ version: _c_qcow2.uint32
42
+ backing_file_offset: _c_qcow2.uint64
43
+ backing_file_size: _c_qcow2.uint32
44
+ cluster_bits: _c_qcow2.uint32
45
+ size: _c_qcow2.uint64
46
+ crypt_method: _c_qcow2.uint32
47
+ l1_size: _c_qcow2.uint32
48
+ l1_table_offset: _c_qcow2.uint64
49
+ refcount_table_offset: _c_qcow2.uint64
50
+ refcount_table_clusters: _c_qcow2.uint32
51
+ nb_snapshots: _c_qcow2.uint32
52
+ snapshots_offset: _c_qcow2.uint64
53
+ incompatible_features: _c_qcow2.uint64
54
+ compatible_features: _c_qcow2.uint64
55
+ autoclear_features: _c_qcow2.uint64
56
+ refcount_order: _c_qcow2.uint32
57
+ header_length: _c_qcow2.uint32
58
+ compression_type: _c_qcow2.uint8
59
+ padding: __cs__.Array[_c_qcow2.uint8]
60
+ @overload
61
+ def __init__(
62
+ self,
63
+ magic: _c_qcow2.uint32 | None = ...,
64
+ version: _c_qcow2.uint32 | None = ...,
65
+ backing_file_offset: _c_qcow2.uint64 | None = ...,
66
+ backing_file_size: _c_qcow2.uint32 | None = ...,
67
+ cluster_bits: _c_qcow2.uint32 | None = ...,
68
+ size: _c_qcow2.uint64 | None = ...,
69
+ crypt_method: _c_qcow2.uint32 | None = ...,
70
+ l1_size: _c_qcow2.uint32 | None = ...,
71
+ l1_table_offset: _c_qcow2.uint64 | None = ...,
72
+ refcount_table_offset: _c_qcow2.uint64 | None = ...,
73
+ refcount_table_clusters: _c_qcow2.uint32 | None = ...,
74
+ nb_snapshots: _c_qcow2.uint32 | None = ...,
75
+ snapshots_offset: _c_qcow2.uint64 | None = ...,
76
+ incompatible_features: _c_qcow2.uint64 | None = ...,
77
+ compatible_features: _c_qcow2.uint64 | None = ...,
78
+ autoclear_features: _c_qcow2.uint64 | None = ...,
79
+ refcount_order: _c_qcow2.uint32 | None = ...,
80
+ header_length: _c_qcow2.uint32 | None = ...,
81
+ compression_type: _c_qcow2.uint8 | None = ...,
82
+ padding: __cs__.Array[_c_qcow2.uint8] | None = ...,
83
+ ): ...
84
+ @overload
85
+ def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ...
86
+
87
+ class QCowExtension(__cs__.Structure):
88
+ magic: _c_qcow2.uint32
89
+ len: _c_qcow2.uint32
90
+ @overload
91
+ def __init__(self, magic: _c_qcow2.uint32 | None = ..., len: _c_qcow2.uint32 | None = ...): ...
92
+ @overload
93
+ def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ...
94
+
95
+ class Qcow2CryptoHeaderExtension(__cs__.Structure):
96
+ offset: _c_qcow2.uint64
97
+ length: _c_qcow2.uint64
98
+ @overload
99
+ def __init__(self, offset: _c_qcow2.uint64 | None = ..., length: _c_qcow2.uint64 | None = ...): ...
100
+ @overload
101
+ def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ...
102
+
103
+ class Qcow2BitmapHeaderExt(__cs__.Structure):
104
+ nb_bitmaps: _c_qcow2.uint32
105
+ reserved32: _c_qcow2.uint32
106
+ bitmap_directory_size: _c_qcow2.uint64
107
+ bitmap_directory_offset: _c_qcow2.uint64
108
+ @overload
109
+ def __init__(
110
+ self,
111
+ nb_bitmaps: _c_qcow2.uint32 | None = ...,
112
+ reserved32: _c_qcow2.uint32 | None = ...,
113
+ bitmap_directory_size: _c_qcow2.uint64 | None = ...,
114
+ bitmap_directory_offset: _c_qcow2.uint64 | None = ...,
115
+ ): ...
116
+ @overload
117
+ def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ...
118
+
119
+ class QCowSnapshotHeader(__cs__.Structure):
120
+ l1_table_offset: _c_qcow2.uint64
121
+ l1_size: _c_qcow2.uint32
122
+ id_str_size: _c_qcow2.uint16
123
+ name_size: _c_qcow2.uint16
124
+ date_sec: _c_qcow2.uint32
125
+ date_nsec: _c_qcow2.uint32
126
+ vm_clock_nsec: _c_qcow2.uint64
127
+ vm_state_size: _c_qcow2.uint32
128
+ extra_data_size: _c_qcow2.uint32
129
+ @overload
130
+ def __init__(
131
+ self,
132
+ l1_table_offset: _c_qcow2.uint64 | None = ...,
133
+ l1_size: _c_qcow2.uint32 | None = ...,
134
+ id_str_size: _c_qcow2.uint16 | None = ...,
135
+ name_size: _c_qcow2.uint16 | None = ...,
136
+ date_sec: _c_qcow2.uint32 | None = ...,
137
+ date_nsec: _c_qcow2.uint32 | None = ...,
138
+ vm_clock_nsec: _c_qcow2.uint64 | None = ...,
139
+ vm_state_size: _c_qcow2.uint32 | None = ...,
140
+ extra_data_size: _c_qcow2.uint32 | None = ...,
141
+ ): ...
142
+ @overload
143
+ def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ...
144
+
145
+ class QCowSnapshotExtraData(__cs__.Structure):
146
+ vm_state_size_large: _c_qcow2.uint64
147
+ disk_size: _c_qcow2.uint64
148
+ icount: _c_qcow2.uint64
149
+ @overload
150
+ def __init__(
151
+ self,
152
+ vm_state_size_large: _c_qcow2.uint64 | None = ...,
153
+ disk_size: _c_qcow2.uint64 | None = ...,
154
+ icount: _c_qcow2.uint64 | None = ...,
155
+ ): ...
156
+ @overload
157
+ def __init__(self, fh: bytes | memoryview | bytearray | BinaryIO, /): ...
158
+
159
+ class QCow2ClusterType(__cs__.Enum):
160
+ QCOW2_CLUSTER_UNALLOCATED = ...
161
+ QCOW2_CLUSTER_ZERO_PLAIN = ...
162
+ QCOW2_CLUSTER_ZERO_ALLOC = ...
163
+ QCOW2_CLUSTER_NORMAL = ...
164
+ QCOW2_CLUSTER_COMPRESSED = ...
165
+
166
+ class QCow2SubclusterType(__cs__.Enum):
167
+ QCOW2_SUBCLUSTER_UNALLOCATED_PLAIN = ...
168
+ QCOW2_SUBCLUSTER_UNALLOCATED_ALLOC = ...
169
+ QCOW2_SUBCLUSTER_ZERO_PLAIN = ...
170
+ QCOW2_SUBCLUSTER_ZERO_ALLOC = ...
171
+ QCOW2_SUBCLUSTER_NORMAL = ...
172
+ QCOW2_SUBCLUSTER_COMPRESSED = ...
173
+ QCOW2_SUBCLUSTER_INVALID = ...
174
+
175
+ # Technically `c_qcow2` is an instance of `_c_qcow2`, but then we can't use it in type hints
176
+ c_qcow2: TypeAlias = _c_qcow2
177
+
178
+ QCOW2_MAGIC: int
179
+ QCOW2_MAGIC_BYTES: bytes
180
+
181
+ QCOW2_INCOMPAT_MASK: int
182
+
183
+ QCow2ClusterType: TypeAlias = _c_qcow2.QCow2ClusterType
184
+ QCow2SubclusterType: TypeAlias = _c_qcow2.QCow2SubclusterType
185
+
186
+ NORMAL_SUBCLUSTER_TYPES: tuple[QCow2SubclusterType, ...]
187
+ ZERO_SUBCLUSTER_TYPES: tuple[QCow2SubclusterType, ...]
188
+ UNALLOCATED_SUBCLUSTER_TYPES: tuple[QCow2SubclusterType, ...]
189
+
190
+ def ctz(value: int) -> int: ...
@@ -3,10 +3,10 @@
3
3
  # - https://github.com/qemu/qemu/blob/master/docs/interop/qcow2.txt
4
4
  from __future__ import annotations
5
5
 
6
- import copy
7
6
  import zlib
8
7
  from functools import cached_property, lru_cache
9
8
  from io import BytesIO
9
+ from pathlib import Path
10
10
  from typing import TYPE_CHECKING, BinaryIO
11
11
 
12
12
  from dissect.util.stream import AlignedStream
@@ -14,6 +14,7 @@ from dissect.util.stream import AlignedStream
14
14
  from dissect.hypervisor.disk.c_qcow2 import (
15
15
  NORMAL_SUBCLUSTER_TYPES,
16
16
  QCOW2_MAGIC,
17
+ QCOW2_MAGIC_BYTES,
17
18
  UNALLOCATED_SUBCLUSTER_TYPES,
18
19
  ZERO_SUBCLUSTER_TYPES,
19
20
  QCow2ClusterType,
@@ -34,26 +35,42 @@ except ImportError:
34
35
  HAS_ZSTD = False
35
36
 
36
37
 
37
- ALLOW_NO_BACKING_FILE = 1
38
-
39
-
40
- class QCow2(AlignedStream):
38
+ class QCow2:
41
39
  """QCOW2 virtual disk implementation.
42
40
 
43
- Supports both data-file and backing-file, but must be manually given as arguments.
44
-
45
- If a data-file is required, it's required to manually pass a file like object
46
- as the `data_file` argument.
47
-
48
- A backing-file can optionally be skipped if `qcow2.ALLOW_NO_BACKING_FILE` is passed
49
- as the `backing_file` argument. In this case, any reads from a backing file will result
50
- in all null bytes being read.
51
- """
41
+ If a data-file is required and ``fh`` is not a ``Path``, it's required to manually pass a file like object
42
+ in the `data_file` argument. Otherwise, the data file will be automatically opened if it exists in the same directory.
43
+ It's possible to defer opening the data file by passing ``allow_no_data_file=True``.
44
+
45
+ The same applies to the backing-file. This too can be deferred by passing ``allow_no_backing_file=True``.
46
+
47
+ Args:
48
+ fh: File handle or path to the QCOW2 file.
49
+ data_file: Optional file handle for the data file. If not provided and ``fh`` is a ``Path``, it will try to open it automatically.
50
+ backing_file: Optional file handle for the backing file. If not provided and ``fh`` is a ``Path``, it will try to open it automatically.
51
+ allow_no_data_file: If True, allows the QCOW2 file to be opened without a data file.
52
+ allow_no_backing_file: If True, allows the QCOW2 file to be opened without a backing file.
53
+ """ # noqa: E501
54
+
55
+ def __init__(
56
+ self,
57
+ fh: BinaryIO | Path,
58
+ data_file: BinaryIO | None = None,
59
+ backing_file: BinaryIO | None = None,
60
+ *,
61
+ allow_no_data_file: bool = False,
62
+ allow_no_backing_file: bool = False,
63
+ ):
64
+ if isinstance(fh, Path):
65
+ self.path = fh
66
+ self.fh = self.path.open("rb")
67
+ else:
68
+ self.path = None
69
+ self.fh = fh
52
70
 
53
- def __init__(self, fh: BinaryIO, data_file: BinaryIO | None = None, backing_file: BinaryIO | int | None = None):
54
- self.fh = fh
71
+ self.fh.seek(0)
55
72
 
56
- self.header = c_qcow2.QCowHeader(fh)
73
+ self.header = c_qcow2.QCowHeader(self.fh)
57
74
  if self.header.magic != QCOW2_MAGIC:
58
75
  raise InvalidHeaderError("Invalid qcow2 header magic")
59
76
 
@@ -63,14 +80,16 @@ class QCow2(AlignedStream):
63
80
  if self.header.cluster_bits < c_qcow2.MIN_CLUSTER_BITS or self.header.cluster_bits > c_qcow2.MAX_CLUSTER_BITS:
64
81
  raise InvalidHeaderError(f"Unsupported cluster size: 2**{self.header.cluster_bits}")
65
82
 
83
+ self.size = self.header.size
84
+
66
85
  self.cluster_bits = self.header.cluster_bits
67
86
  self.cluster_size = 1 << self.cluster_bits
68
87
  self.subclusters_per_cluster = c_qcow2.QCOW_EXTL2_SUBCLUSTERS_PER_CLUSTER if self.has_subclusters else 1
69
88
  self.subcluster_size = self.cluster_size // self.subclusters_per_cluster
70
- self.subcluster_bits = ctz(self.subcluster_size, 32)
89
+ self.subcluster_bits = ctz(self.subcluster_size)
71
90
 
72
91
  self._l2_entry_size = c_qcow2.L2E_SIZE_EXTENDED if self.has_subclusters else c_qcow2.L2E_SIZE_NORMAL
73
- self.l2_bits = self.cluster_bits - ctz(self._l2_entry_size, 32)
92
+ self.l2_bits = self.cluster_bits - ctz(self._l2_entry_size)
74
93
  self.l2_size = 1 << self.l2_bits
75
94
 
76
95
  # 104 = byte offset of compression_type
@@ -100,10 +119,8 @@ class QCow2(AlignedStream):
100
119
  self.unknown_extensions = []
101
120
  self._read_extensions()
102
121
 
103
- if self.header.incompatible_features & c_qcow2.QCOW2_INCOMPAT_DATA_FILE:
104
- if data_file is None:
105
- raise Error(f"data-file required but not provided (image_data_file = {self.image_data_file})")
106
- self.data_file = data_file
122
+ if self.needs_data_file:
123
+ self.data_file = self._open_data_file(data_file, allow_no_data_file)
107
124
  else:
108
125
  self.data_file = self.fh
109
126
 
@@ -115,16 +132,10 @@ class QCow2(AlignedStream):
115
132
  self.auto_backing_file = self.fh.read(self.header.backing_file_size).decode()
116
133
  self.image_backing_file = self.auto_backing_file.upper()
117
134
 
118
- if backing_file is None:
119
- raise Error(f"backing-file required but not provided (auto_backing_file = {self.auto_backing_file})")
120
-
121
- if backing_file != ALLOW_NO_BACKING_FILE:
122
- self.backing_file = backing_file
135
+ self.backing_file = self._open_backing_file(backing_file, allow_no_backing_file)
123
136
 
124
137
  self.l2_table = lru_cache(128)(self.l2_table)
125
138
 
126
- super().__init__(self.header.size)
127
-
128
139
  def _read_extensions(self) -> None:
129
140
  start_offset = self.header.header_length
130
141
  end_offset = self.header.backing_file_offset or 1 << self.cluster_bits
@@ -158,14 +169,54 @@ class QCow2(AlignedStream):
158
169
  # Align to nearest 8 byte boundary
159
170
  offset += (ext.len + 7) & 0xFFFFFFF8
160
171
 
172
+ def _open_data_file(self, data_file: BinaryIO | None, allow_no_data_file: bool = False) -> BinaryIO | None:
173
+ if data_file is not None:
174
+ return data_file
175
+
176
+ if self.path:
177
+ if (data_file_path := self.path.with_name(self.image_data_file)).exists():
178
+ return data_file_path.open("rb")
179
+
180
+ if not allow_no_data_file:
181
+ raise Error(f"data-file {str(data_file_path)!r} not found (image_data_file = {self.image_data_file!r})")
182
+ elif allow_no_data_file:
183
+ return None
184
+
185
+ raise Error(f"data-file required but not provided (image_data_file = {self.image_data_file!r})")
186
+
187
+ def _open_backing_file(self, backing_file: BinaryIO | None, allow_no_backing_file: bool = False) -> BinaryIO | None:
188
+ backing_file_path = None
189
+ if backing_file is None:
190
+ if self.path:
191
+ if (backing_file_path := self.path.with_name(self.auto_backing_file)).exists():
192
+ backing_file = backing_file_path.open("rb")
193
+ elif not allow_no_backing_file:
194
+ raise Error(
195
+ f"backing-file {str(backing_file_path)!r} not found (auto_backing_file = {self.auto_backing_file!r})" # noqa: E501
196
+ )
197
+ elif not allow_no_backing_file:
198
+ raise Error(f"backing-file required but not provided (auto_backing_file = {self.auto_backing_file!r})")
199
+
200
+ if backing_file:
201
+ if backing_file.read(4) == QCOW2_MAGIC_BYTES:
202
+ if backing_file_path:
203
+ backing_file.close()
204
+ backing_file = QCow2(backing_file_path).open()
205
+ else:
206
+ backing_file = QCow2(backing_file).open()
207
+ else:
208
+ backing_file.seek(0)
209
+
210
+ return backing_file
211
+
161
212
  @cached_property
162
213
  def snapshots(self) -> list[QCow2Snapshot]:
163
- snapshots = []
214
+ snapshots: list[QCow2Snapshot] = []
164
215
 
165
216
  offset = self.header.snapshots_offset
166
217
  for _ in range(self.header.nb_snapshots):
167
218
  snapshots.append(QCow2Snapshot(self, offset))
168
- offset += snapshots[-1].entry_size
219
+ offset += (snapshots[-1].entry_size + 7) & ~7 # Round up to 8 bytes
169
220
 
170
221
  return snapshots
171
222
 
@@ -182,65 +233,72 @@ class QCow2(AlignedStream):
182
233
  def has_backing_file(self) -> bool:
183
234
  return self.backing_file is not None
184
235
 
236
+ @property
237
+ def needs_backing_file(self) -> bool:
238
+ return self.header.backing_file_offset != 0
239
+
185
240
  @property
186
241
  def has_data_file(self) -> bool:
187
- return self.data_file != self.fh
242
+ return self.data_file is not None and self.data_file != self.fh
243
+
244
+ @property
245
+ def needs_data_file(self) -> bool:
246
+ return bool(self.header.incompatible_features & c_qcow2.QCOW2_INCOMPAT_DATA_FILE)
188
247
 
189
248
  @property
190
249
  def has_subclusters(self) -> bool:
191
250
  return bool(self.header.incompatible_features & c_qcow2.QCOW2_INCOMPAT_EXTL2)
192
251
 
193
- def _read(self, offset: int, length: int) -> bytes:
194
- result = []
252
+ def open(self) -> QCow2Stream:
253
+ """Open the QCow2 file for reading."""
254
+ if self.needs_data_file and not self.has_data_file:
255
+ raise Error(f"data-file required but not provided (image_data_file = {self.image_data_file!r})")
256
+ if self.needs_backing_file and not self.has_backing_file:
257
+ raise Error(f"backing-file required but not provided (auto_backing_file = {self.auto_backing_file!r})")
258
+ return QCow2Stream(self)
195
259
 
196
- for sc_type, read_offset, run_offset, run_length in self._yield_runs(offset, length):
197
- unalloc_zeroed = sc_type in UNALLOCATED_SUBCLUSTER_TYPES and not self.has_backing_file
198
260
 
199
- if sc_type in ZERO_SUBCLUSTER_TYPES or unalloc_zeroed:
200
- result.append(b"\x00" * run_length)
201
- elif sc_type in UNALLOCATED_SUBCLUSTER_TYPES and self.has_backing_file:
202
- self.backing_file.seek(read_offset)
203
- result.append(self.backing_file.read(run_length))
204
- elif sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED:
205
- result.append(self._read_compressed(run_offset, read_offset, run_length))
206
- elif sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL:
207
- self.data_file.seek(run_offset)
208
- result.append(self.data_file.read(run_length))
261
+ class QCow2Snapshot:
262
+ """Wrapper class for snapshot table entries."""
209
263
 
210
- return b"".join(result)
264
+ def __init__(self, qcow2: QCow2, offset: int):
265
+ self.qcow2 = qcow2
266
+ self.offset = offset
211
267
 
212
- def _read_compressed(self, cluster_descriptor: int, offset: int, length: int) -> bytes:
213
- offset_in_cluster = offset_into_cluster(self, offset)
214
- coffset = cluster_descriptor & self.cluster_offset_mask
215
- nb_csectors = ((cluster_descriptor >> self.csize_shift) & self.csize_mask) + 1
216
- # Original source uses the mask ~(~(QCOW2_COMPRESSED_SECTOR_SIZE - 1ULL))
217
- # However bit inversion is weird in Python, and this evaluates to 511, so we use that value instead.
218
- csize = nb_csectors * c_qcow2.QCOW2_COMPRESSED_SECTOR_SIZE - (coffset & 511)
268
+ self.qcow2.fh.seek(offset)
269
+ self.header = c_qcow2.QCowSnapshotHeader(self.qcow2.fh)
219
270
 
220
- self.fh.seek(coffset)
221
- buf = self.fh.read(csize)
222
- decompressed = self._decompress(buf)
271
+ # Older versions may not have all the extra data fields
272
+ # Instead of reading them manually, just pad the extra data to fit our struct
273
+ extra_data = self.qcow2.fh.read(self.header.extra_data_size)
274
+ self.extra = c_qcow2.QCowSnapshotExtraData(extra_data.ljust(len(c_qcow2.QCowSnapshotExtraData), b"\x00"))
223
275
 
224
- return decompressed[offset_in_cluster : offset_in_cluster + length]
276
+ unknown_extra_size = self.header.extra_data_size - len(c_qcow2.QCowSnapshotExtraData)
277
+ self.unknown_extra = self.qcow2.fh.read(unknown_extra_size) if unknown_extra_size > 0 else None
225
278
 
226
- def _decompress(self, buf: bytes) -> bytes:
227
- if self.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZLIB:
228
- dctx = zlib.decompressobj(-12)
229
- return dctx.decompress(buf, self.cluster_size)
279
+ self.id = self.qcow2.fh.read(self.header.id_str_size).decode()
280
+ self.name = self.qcow2.fh.read(self.header.name_size).decode()
230
281
 
231
- if self.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZSTD:
232
- result = []
282
+ self.entry_size = self.qcow2.fh.tell() - offset
233
283
 
234
- dctx = zstd.ZstdDecompressor()
235
- reader = dctx.stream_reader(BytesIO(buf))
236
- while reader.tell() < self.cluster_size:
237
- chunk = reader.read(self.cluster_size - reader.tell())
238
- if not chunk:
239
- break
240
- result.append(chunk)
241
- return b"".join(result)
284
+ def open(self) -> QCow2Stream:
285
+ """Open the snapshot for reading."""
286
+ return QCow2Stream(self.qcow2, self.l1_table)
287
+
288
+ @cached_property
289
+ def l1_table(self) -> list[int]:
290
+ # L1 table is usually relatively small, it can be at most 32MB on PB or EB size disks
291
+ self.qcow2.fh.seek(self.header.l1_table_offset)
292
+ return c_qcow2.uint64[self.header.l1_size](self.qcow2.fh)
242
293
 
243
- raise Error(f"Invalid compression type: {self.compression_type}")
294
+
295
+ class QCow2Stream(AlignedStream):
296
+ """Aligned stream for reading QCow2 files."""
297
+
298
+ def __init__(self, qcow2: QCow2, l1_table: list[int] | None = None):
299
+ super().__init__(qcow2.header.size, align=qcow2.cluster_size)
300
+ self.qcow2 = qcow2
301
+ self.l1_table = l1_table or qcow2.l1_table
244
302
 
245
303
  def _yield_runs(self, offset: int, length: int) -> Iterator[tuple[QCow2SubclusterType, int, int, int]]:
246
304
  # reference: qcow2_get_host_offset
@@ -249,19 +307,19 @@ class QCow2(AlignedStream):
249
307
  host_offset = 0
250
308
  read_count = 0
251
309
 
252
- l1_index = offset_to_l1_index(self, offset)
253
- l2_index = offset_to_l2_index(self, offset)
254
- sc_index = offset_to_sc_index(self, offset)
310
+ l1_index = offset_to_l1_index(self.qcow2, offset)
311
+ l2_index = offset_to_l2_index(self.qcow2, offset)
312
+ sc_index = offset_to_sc_index(self.qcow2, offset)
255
313
 
256
- offset_in_cluster = offset_into_cluster(self, offset)
314
+ offset_in_cluster = offset_into_cluster(self.qcow2, offset)
257
315
 
258
316
  bytes_needed = length + offset_in_cluster
259
317
  # at the time being we just use the entire l2 table and not cached slices
260
318
  # this is actually the bytes available/remaining in this l2 table
261
- bytes_available = (self.l2_size - l2_index) << self.cluster_bits
319
+ bytes_available = (self.qcow2.l2_size - l2_index) << self.qcow2.cluster_bits
262
320
  bytes_needed = min(bytes_needed, bytes_available)
263
321
 
264
- if l1_index > self.header.l1_size:
322
+ if l1_index > self.qcow2.header.l1_size:
265
323
  # bytes_needed is already the smaller value here
266
324
  read_count = bytes_needed - offset_in_cluster
267
325
 
@@ -282,11 +340,11 @@ class QCow2(AlignedStream):
282
340
  offset += read_count
283
341
  continue
284
342
 
285
- l2_table = self.l2_table(l2_offset)
343
+ l2_table = self.qcow2.l2_table(l2_offset)
286
344
  l2_entry = l2_table.entry(l2_index)
287
345
  l2_bitmap = l2_table.bitmap(l2_index)
288
346
 
289
- sc_type = get_subcluster_type(self, l2_entry, l2_bitmap, sc_index)
347
+ sc_type = get_subcluster_type(self.qcow2, l2_entry, l2_bitmap, sc_index)
290
348
 
291
349
  if sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED:
292
350
  host_offset = l2_entry & c_qcow2.L2E_COMPRESSED_OFFSET_SIZE_MASK
@@ -294,10 +352,10 @@ class QCow2(AlignedStream):
294
352
  host_cluster_offset = l2_entry & c_qcow2.L2E_OFFSET_MASK
295
353
  host_offset = host_cluster_offset + offset_in_cluster
296
354
 
297
- nb_clusters = size_to_clusters(self, bytes_needed)
298
- sc_count = count_contiguous_subclusters(self, nb_clusters, sc_index, l2_table, l2_index)
355
+ nb_clusters = size_to_clusters(self.qcow2, bytes_needed)
356
+ sc_count = count_contiguous_subclusters(self.qcow2, nb_clusters, sc_index, l2_table, l2_index)
299
357
  # this is the amount of contiguous bytes available of the same subcluster type
300
- bytes_available = (sc_count + sc_index) << self.subcluster_bits
358
+ bytes_available = (sc_count + sc_index) << self.qcow2.subcluster_bits
301
359
 
302
360
  # account for the offset in the cluster
303
361
  read_count = min(bytes_available, bytes_needed) - offset_in_cluster
@@ -306,6 +364,58 @@ class QCow2(AlignedStream):
306
364
  length -= read_count
307
365
  offset += read_count
308
366
 
367
+ def _read_compressed(self, cluster_descriptor: int, offset: int, length: int) -> bytes:
368
+ offset_in_cluster = offset_into_cluster(self.qcow2, offset)
369
+ coffset = cluster_descriptor & self.qcow2.cluster_offset_mask
370
+ nb_csectors = ((cluster_descriptor >> self.qcow2.csize_shift) & self.qcow2.csize_mask) + 1
371
+ # Original source uses the mask ~(~(QCOW2_COMPRESSED_SECTOR_SIZE - 1ULL))
372
+ # However bit inversion is weird in Python, and this evaluates to 511, so we use that value instead.
373
+ csize = nb_csectors * c_qcow2.QCOW2_COMPRESSED_SECTOR_SIZE - (coffset & 511)
374
+
375
+ self.qcow2.fh.seek(coffset)
376
+ buf = self.qcow2.fh.read(csize)
377
+ decompressed = self._decompress(buf)
378
+
379
+ return decompressed[offset_in_cluster : offset_in_cluster + length]
380
+
381
+ def _decompress(self, buf: bytes) -> bytes:
382
+ if self.qcow2.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZLIB:
383
+ dctx = zlib.decompressobj(-12)
384
+ return dctx.decompress(buf, self.qcow2.cluster_size)
385
+
386
+ if self.qcow2.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZSTD:
387
+ result = []
388
+
389
+ dctx = zstd.ZstdDecompressor()
390
+ reader = dctx.stream_reader(BytesIO(buf))
391
+ while reader.tell() < self.qcow2.cluster_size:
392
+ chunk = reader.read(self.qcow2.cluster_size - reader.tell())
393
+ if not chunk:
394
+ break
395
+ result.append(chunk)
396
+ return b"".join(result)
397
+
398
+ raise Error(f"Invalid compression type: {self.qcow2.compression_type}")
399
+
400
+ def _read(self, offset: int, length: int) -> bytes:
401
+ result = []
402
+
403
+ for sc_type, read_offset, run_offset, run_length in self._yield_runs(offset, length):
404
+ unalloc_zeroed = sc_type in UNALLOCATED_SUBCLUSTER_TYPES and not self.qcow2.has_backing_file
405
+
406
+ if sc_type in ZERO_SUBCLUSTER_TYPES or unalloc_zeroed:
407
+ result.append(b"\x00" * run_length)
408
+ elif sc_type in UNALLOCATED_SUBCLUSTER_TYPES and self.qcow2.has_backing_file:
409
+ self.qcow2.backing_file.seek(read_offset)
410
+ result.append(self.qcow2.backing_file.read(run_length))
411
+ elif sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED:
412
+ result.append(self._read_compressed(run_offset, read_offset, run_length))
413
+ elif sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL:
414
+ self.qcow2.data_file.seek(run_offset)
415
+ result.append(self.qcow2.data_file.read(run_length))
416
+
417
+ return b"".join(result)
418
+
309
419
 
310
420
  class L2Table:
311
421
  """Convenience class for accessing the L2 table."""
@@ -327,42 +437,6 @@ class L2Table:
327
437
  return 0
328
438
 
329
439
 
330
- class QCow2Snapshot:
331
- """Wrapper class for snapshot table entries."""
332
-
333
- def __init__(self, qcow2: QCow2, offset: int):
334
- self.qcow2 = qcow2
335
- self.offset = offset
336
-
337
- self.qcow2.fh.seek(offset)
338
- self.header = c_qcow2.QCowSnapshotHeader(self.qcow2.fh)
339
-
340
- # Older versions may not have all the extra data fields
341
- # Instead of reading them manually, just pad the extra data to fit our struct
342
- extra_data = self.qcow2.fh.read(self.header.extra_data_size)
343
- self.extra = c_qcow2.QCowSnapshotExtraData(extra_data.ljust(len(c_qcow2.QCowSnapshotExtraData), b"\x00"))
344
-
345
- unknown_extra_size = self.header.extra_data_size - len(c_qcow2.QCowSnapshotExtraData)
346
- self.unknown_extra = self.qcow2.fh.read(unknown_extra_size) if unknown_extra_size > 0 else None
347
-
348
- self.id_str = self.qcow2.fh.read(self.header.id_str_size).decode()
349
- self.name = self.qcow2.fh.read(self.header.name_size).decode()
350
-
351
- self.entry_size = self.qcow2.fh.tell() - offset
352
-
353
- def open(self) -> QCow2:
354
- disk = copy.copy(self.qcow2)
355
- disk.l1_table = self.l1_table
356
- disk.seek(0)
357
- return disk
358
-
359
- @cached_property
360
- def l1_table(self) -> list[int]:
361
- # L1 table is usually relatively small, it can be at most 32MB on PB or EB size disks
362
- self.qcow2.fh.seek(self.header.l1_table_offset)
363
- return c_qcow2.uint64[self.header.l1_size](self.qcow2.fh)
364
-
365
-
366
440
  def offset_into_cluster(qcow2: QCow2, offset: int) -> int:
367
441
  return offset & (qcow2.cluster_size - 1)
368
442
 
@@ -460,16 +534,16 @@ def get_subcluster_range_type(
460
534
  sc_mask = (1 << sc_from) - 1
461
535
  if sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL:
462
536
  val = l2_bitmap | sc_mask # QCOW_OFLAG_SUB_ALLOC_RANGE(0, sc_from)
463
- return sc_type, ctz(val, 32) - sc_from
537
+ return sc_type, ctz(val) - sc_from
464
538
  if sc_type in ZERO_SUBCLUSTER_TYPES:
465
539
  val = (l2_bitmap | sc_mask) >> 32 # QCOW_OFLAG_SUB_ZERO_RANGE(0, sc_from)
466
- return sc_type, ctz(val, 32) - sc_from
540
+ return sc_type, ctz(val) - sc_from
467
541
  if sc_type in UNALLOCATED_SUBCLUSTER_TYPES:
468
542
  # We need to mask it with a 64bit mask because Python flips the sign bit
469
543
  inv_mask = ~sc_mask & ((1 << 64) - 1) # ~QCOW_OFLAG_SUB_ALLOC_RANGE(0, sc_from)
470
544
 
471
545
  val = ((l2_bitmap >> 32) | l2_bitmap) & inv_mask
472
- return sc_type, ctz(val, 32) - sc_from
546
+ return sc_type, ctz(val) - sc_from
473
547
 
474
548
  raise Error(f"Invalid subcluster type: {sc_type}")
475
549
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dissect.hypervisor
3
- Version: 3.18.dev1
3
+ Version: 3.19.dev2
4
4
  Summary: A Dissect module implementing parsers for various hypervisor disk, backup and configuration files
5
5
  Author-email: Dissect Team <dissect@fox-it.com>
6
6
  License: Affero General Public License v3
@@ -9,13 +9,14 @@ dissect/hypervisor/descriptor/vbox.py,sha256=TrfMN0BppjIoDeHVCMKlH1OS0IHO-V7uRFo
9
9
  dissect/hypervisor/descriptor/vmx.py,sha256=6xhX7RlDqyn9XBkiq2NnQ5LrLL0nXwiPGOPy3WVVAtI,12647
10
10
  dissect/hypervisor/disk/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  dissect/hypervisor/disk/c_hdd.py,sha256=wrJ5qwyf7QCZ3Pl-I8eYDR4CUUo4pAFhjzs8huRGqs8,2235
12
- dissect/hypervisor/disk/c_qcow2.py,sha256=oD4GaUGkTEwuusFg7I4uX3MNX_LtQKZS8QlYOz7ZnoY,5115
12
+ dissect/hypervisor/disk/c_qcow2.py,sha256=jOr2fQmDQuegbE1qDX2x2xaA4RS0LKMsOCI5UwKJxhg,5071
13
+ dissect/hypervisor/disk/c_qcow2.pyi,sha256=Rf-tCTf78F2fsVBYw04cZ2vNq2Xns7P1f86oSVe7zEU,7896
13
14
  dissect/hypervisor/disk/c_vdi.py,sha256=lKk5MnCImdfyUyGDe_yY_ntLd1ITkKEvMGIMCHiHC2w,3843
14
15
  dissect/hypervisor/disk/c_vhd.py,sha256=gYOwOdvhMKNLflugkpjmdn__ZUJBlxeByx6P0ePNQ00,1493
15
16
  dissect/hypervisor/disk/c_vhdx.py,sha256=4yNU51jfYPEBFXEZAvaF8cFdHBRlmCe_OU5ZU83-kco,2920
16
17
  dissect/hypervisor/disk/c_vmdk.py,sha256=lfObMyD9r-20DCDwVDm9xT98cq3aE4MCJF5TpDBNdd4,5988
17
18
  dissect/hypervisor/disk/hdd.py,sha256=ChKtQIQrssIgZyUv86JE9-n4XdjDT14pc6EO6mZllQg,12981
18
- dissect/hypervisor/disk/qcow2.py,sha256=mpLtCZChb1cMdKwNY0fxaORTMtVRTNZfel8nHlMegt0,20856
19
+ dissect/hypervisor/disk/qcow2.py,sha256=j7GSgzYceSq6Nx7vw4M7Cm5irKqGDql1t9apl2gC1FY,24509
19
20
  dissect/hypervisor/disk/vdi.py,sha256=Ezm_vmi6ZKLTgPx5chXmtBzkBOCxilBWUrdqQdq_4KA,1958
20
21
  dissect/hypervisor/disk/vhd.py,sha256=GfEfFp5g8OlV0MrTXqSfBRzMMvIZO_T1A8d05SeGiBk,3948
21
22
  dissect/hypervisor/disk/vhdx.py,sha256=Yidz57PsH6IGRiN8cpfAnyZiOGt1qApd8ohVdHGF2mY,13215
@@ -26,10 +27,10 @@ dissect/hypervisor/tools/vmtar.py,sha256=bAf_rEhqUmeI8my22p7L9uV3SgB5C_61YQKfD3t
26
27
  dissect/hypervisor/util/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
27
28
  dissect/hypervisor/util/envelope.py,sha256=EhZjZh3EezqxkJ84OgqpptHIhpVWPcUkFMjdYU-iAnU,10442
28
29
  dissect/hypervisor/util/vmtar.py,sha256=xzc4bd5RnBgP9_dCU9i-ZwXI_0S3N7gCfMzElwwnd5s,4004
29
- dissect_hypervisor-3.18.dev1.dist-info/licenses/COPYRIGHT,sha256=EOOoIwk_inOMUD4c1ylpzMtYLjGzmc-MLEVAEdLLr20,305
30
- dissect_hypervisor-3.18.dev1.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
31
- dissect_hypervisor-3.18.dev1.dist-info/METADATA,sha256=I1eM8mXEJRNMCt4RfSLZQ1-FU730qvKO5xV6pqcw1WI,3434
32
- dissect_hypervisor-3.18.dev1.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
33
- dissect_hypervisor-3.18.dev1.dist-info/entry_points.txt,sha256=kW36GnJ3G1dSRYFL4r8Bj32Al_CkGqST3bdHvo3pyiY,120
34
- dissect_hypervisor-3.18.dev1.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
35
- dissect_hypervisor-3.18.dev1.dist-info/RECORD,,
30
+ dissect_hypervisor-3.19.dev2.dist-info/licenses/COPYRIGHT,sha256=EOOoIwk_inOMUD4c1ylpzMtYLjGzmc-MLEVAEdLLr20,305
31
+ dissect_hypervisor-3.19.dev2.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
32
+ dissect_hypervisor-3.19.dev2.dist-info/METADATA,sha256=58CgYl9rnPBcQauWn5zLrrdF6jR6N6oI1eYENq8W3ek,3434
33
+ dissect_hypervisor-3.19.dev2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
34
+ dissect_hypervisor-3.19.dev2.dist-info/entry_points.txt,sha256=kW36GnJ3G1dSRYFL4r8Bj32Al_CkGqST3bdHvo3pyiY,120
35
+ dissect_hypervisor-3.19.dev2.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
36
+ dissect_hypervisor-3.19.dev2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.7.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5