dissect.hypervisor 3.19.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,7 +3,6 @@
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
@@ -15,6 +14,7 @@ from dissect.util.stream import AlignedStream
15
14
  from dissect.hypervisor.disk.c_qcow2 import (
16
15
  NORMAL_SUBCLUSTER_TYPES,
17
16
  QCOW2_MAGIC,
17
+ QCOW2_MAGIC_BYTES,
18
18
  UNALLOCATED_SUBCLUSTER_TYPES,
19
19
  ZERO_SUBCLUSTER_TYPES,
20
20
  QCow2ClusterType,
@@ -35,28 +35,42 @@ except ImportError:
35
35
  HAS_ZSTD = False
36
36
 
37
37
 
38
- ALLOW_NO_BACKING_FILE = 1
39
-
40
-
41
- class QCow2(AlignedStream):
38
+ class QCow2:
42
39
  """QCOW2 virtual disk implementation.
43
40
 
44
- Supports both data-file and backing-file, but must be manually given as arguments.
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``.
45
44
 
46
- If a data-file is required, it's required to manually pass a file like object
47
- as the `data_file` argument.
45
+ The same applies to the backing-file. This too can be deferred by passing ``allow_no_backing_file=True``.
48
46
 
49
- A backing-file can optionally be skipped if `qcow2.ALLOW_NO_BACKING_FILE` is passed
50
- as the `backing_file` argument. In this case, any reads from a backing file will result
51
- in all null bytes being read.
52
- """
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
53
54
 
54
55
  def __init__(
55
- self, fh: BinaryIO | Path, data_file: BinaryIO | None = None, backing_file: BinaryIO | int | None = None
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,
56
63
  ):
57
- self.fh = fh.open("rb") if isinstance(fh, Path) else fh
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
58
70
 
59
- self.header = c_qcow2.QCowHeader(fh)
71
+ self.fh.seek(0)
72
+
73
+ self.header = c_qcow2.QCowHeader(self.fh)
60
74
  if self.header.magic != QCOW2_MAGIC:
61
75
  raise InvalidHeaderError("Invalid qcow2 header magic")
62
76
 
@@ -66,14 +80,16 @@ class QCow2(AlignedStream):
66
80
  if self.header.cluster_bits < c_qcow2.MIN_CLUSTER_BITS or self.header.cluster_bits > c_qcow2.MAX_CLUSTER_BITS:
67
81
  raise InvalidHeaderError(f"Unsupported cluster size: 2**{self.header.cluster_bits}")
68
82
 
83
+ self.size = self.header.size
84
+
69
85
  self.cluster_bits = self.header.cluster_bits
70
86
  self.cluster_size = 1 << self.cluster_bits
71
87
  self.subclusters_per_cluster = c_qcow2.QCOW_EXTL2_SUBCLUSTERS_PER_CLUSTER if self.has_subclusters else 1
72
88
  self.subcluster_size = self.cluster_size // self.subclusters_per_cluster
73
- self.subcluster_bits = ctz(self.subcluster_size, 32)
89
+ self.subcluster_bits = ctz(self.subcluster_size)
74
90
 
75
91
  self._l2_entry_size = c_qcow2.L2E_SIZE_EXTENDED if self.has_subclusters else c_qcow2.L2E_SIZE_NORMAL
76
- self.l2_bits = self.cluster_bits - ctz(self._l2_entry_size, 32)
92
+ self.l2_bits = self.cluster_bits - ctz(self._l2_entry_size)
77
93
  self.l2_size = 1 << self.l2_bits
78
94
 
79
95
  # 104 = byte offset of compression_type
@@ -103,10 +119,8 @@ class QCow2(AlignedStream):
103
119
  self.unknown_extensions = []
104
120
  self._read_extensions()
105
121
 
106
- if self.header.incompatible_features & c_qcow2.QCOW2_INCOMPAT_DATA_FILE:
107
- if data_file is None:
108
- raise Error(f"data-file required but not provided (image_data_file = {self.image_data_file})")
109
- 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)
110
124
  else:
111
125
  self.data_file = self.fh
112
126
 
@@ -118,24 +132,10 @@ class QCow2(AlignedStream):
118
132
  self.auto_backing_file = self.fh.read(self.header.backing_file_size).decode()
119
133
  self.image_backing_file = self.auto_backing_file.upper()
120
134
 
121
- if backing_file is None:
122
- if not isinstance(fh, Path):
123
- raise Error(
124
- f"backing-file required but not provided (auto_backing_file = {self.auto_backing_file})"
125
- )
126
- if not (candidate_path := fh.parent.joinpath(self.auto_backing_file)).exists():
127
- raise Error(
128
- f"backing-file '{candidate_path}' not found (auto_backing_file = '{self.auto_backing_file}')"
129
- )
130
- backing_file = candidate_path.open("rb")
131
-
132
- if backing_file != ALLOW_NO_BACKING_FILE:
133
- self.backing_file = backing_file
135
+ self.backing_file = self._open_backing_file(backing_file, allow_no_backing_file)
134
136
 
135
137
  self.l2_table = lru_cache(128)(self.l2_table)
136
138
 
137
- super().__init__(self.header.size)
138
-
139
139
  def _read_extensions(self) -> None:
140
140
  start_offset = self.header.header_length
141
141
  end_offset = self.header.backing_file_offset or 1 << self.cluster_bits
@@ -169,14 +169,54 @@ class QCow2(AlignedStream):
169
169
  # Align to nearest 8 byte boundary
170
170
  offset += (ext.len + 7) & 0xFFFFFFF8
171
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
+
172
212
  @cached_property
173
213
  def snapshots(self) -> list[QCow2Snapshot]:
174
- snapshots = []
214
+ snapshots: list[QCow2Snapshot] = []
175
215
 
176
216
  offset = self.header.snapshots_offset
177
217
  for _ in range(self.header.nb_snapshots):
178
218
  snapshots.append(QCow2Snapshot(self, offset))
179
- offset += snapshots[-1].entry_size
219
+ offset += (snapshots[-1].entry_size + 7) & ~7 # Round up to 8 bytes
180
220
 
181
221
  return snapshots
182
222
 
@@ -193,65 +233,72 @@ class QCow2(AlignedStream):
193
233
  def has_backing_file(self) -> bool:
194
234
  return self.backing_file is not None
195
235
 
236
+ @property
237
+ def needs_backing_file(self) -> bool:
238
+ return self.header.backing_file_offset != 0
239
+
196
240
  @property
197
241
  def has_data_file(self) -> bool:
198
- 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)
199
247
 
200
248
  @property
201
249
  def has_subclusters(self) -> bool:
202
250
  return bool(self.header.incompatible_features & c_qcow2.QCOW2_INCOMPAT_EXTL2)
203
251
 
204
- def _read(self, offset: int, length: int) -> bytes:
205
- 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)
206
259
 
207
- for sc_type, read_offset, run_offset, run_length in self._yield_runs(offset, length):
208
- unalloc_zeroed = sc_type in UNALLOCATED_SUBCLUSTER_TYPES and not self.has_backing_file
209
260
 
210
- if sc_type in ZERO_SUBCLUSTER_TYPES or unalloc_zeroed:
211
- result.append(b"\x00" * run_length)
212
- elif sc_type in UNALLOCATED_SUBCLUSTER_TYPES and self.has_backing_file:
213
- self.backing_file.seek(read_offset)
214
- result.append(self.backing_file.read(run_length))
215
- elif sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED:
216
- result.append(self._read_compressed(run_offset, read_offset, run_length))
217
- elif sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL:
218
- self.data_file.seek(run_offset)
219
- result.append(self.data_file.read(run_length))
261
+ class QCow2Snapshot:
262
+ """Wrapper class for snapshot table entries."""
220
263
 
221
- return b"".join(result)
264
+ def __init__(self, qcow2: QCow2, offset: int):
265
+ self.qcow2 = qcow2
266
+ self.offset = offset
222
267
 
223
- def _read_compressed(self, cluster_descriptor: int, offset: int, length: int) -> bytes:
224
- offset_in_cluster = offset_into_cluster(self, offset)
225
- coffset = cluster_descriptor & self.cluster_offset_mask
226
- nb_csectors = ((cluster_descriptor >> self.csize_shift) & self.csize_mask) + 1
227
- # Original source uses the mask ~(~(QCOW2_COMPRESSED_SECTOR_SIZE - 1ULL))
228
- # However bit inversion is weird in Python, and this evaluates to 511, so we use that value instead.
229
- 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)
230
270
 
231
- self.fh.seek(coffset)
232
- buf = self.fh.read(csize)
233
- 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"))
234
275
 
235
- 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
236
278
 
237
- def _decompress(self, buf: bytes) -> bytes:
238
- if self.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZLIB:
239
- dctx = zlib.decompressobj(-12)
240
- 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()
241
281
 
242
- if self.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZSTD:
243
- result = []
282
+ self.entry_size = self.qcow2.fh.tell() - offset
244
283
 
245
- dctx = zstd.ZstdDecompressor()
246
- reader = dctx.stream_reader(BytesIO(buf))
247
- while reader.tell() < self.cluster_size:
248
- chunk = reader.read(self.cluster_size - reader.tell())
249
- if not chunk:
250
- break
251
- result.append(chunk)
252
- 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)
253
293
 
254
- 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
255
302
 
256
303
  def _yield_runs(self, offset: int, length: int) -> Iterator[tuple[QCow2SubclusterType, int, int, int]]:
257
304
  # reference: qcow2_get_host_offset
@@ -260,19 +307,19 @@ class QCow2(AlignedStream):
260
307
  host_offset = 0
261
308
  read_count = 0
262
309
 
263
- l1_index = offset_to_l1_index(self, offset)
264
- l2_index = offset_to_l2_index(self, offset)
265
- 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)
266
313
 
267
- offset_in_cluster = offset_into_cluster(self, offset)
314
+ offset_in_cluster = offset_into_cluster(self.qcow2, offset)
268
315
 
269
316
  bytes_needed = length + offset_in_cluster
270
317
  # at the time being we just use the entire l2 table and not cached slices
271
318
  # this is actually the bytes available/remaining in this l2 table
272
- bytes_available = (self.l2_size - l2_index) << self.cluster_bits
319
+ bytes_available = (self.qcow2.l2_size - l2_index) << self.qcow2.cluster_bits
273
320
  bytes_needed = min(bytes_needed, bytes_available)
274
321
 
275
- if l1_index > self.header.l1_size:
322
+ if l1_index > self.qcow2.header.l1_size:
276
323
  # bytes_needed is already the smaller value here
277
324
  read_count = bytes_needed - offset_in_cluster
278
325
 
@@ -293,11 +340,11 @@ class QCow2(AlignedStream):
293
340
  offset += read_count
294
341
  continue
295
342
 
296
- l2_table = self.l2_table(l2_offset)
343
+ l2_table = self.qcow2.l2_table(l2_offset)
297
344
  l2_entry = l2_table.entry(l2_index)
298
345
  l2_bitmap = l2_table.bitmap(l2_index)
299
346
 
300
- 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)
301
348
 
302
349
  if sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED:
303
350
  host_offset = l2_entry & c_qcow2.L2E_COMPRESSED_OFFSET_SIZE_MASK
@@ -305,10 +352,10 @@ class QCow2(AlignedStream):
305
352
  host_cluster_offset = l2_entry & c_qcow2.L2E_OFFSET_MASK
306
353
  host_offset = host_cluster_offset + offset_in_cluster
307
354
 
308
- nb_clusters = size_to_clusters(self, bytes_needed)
309
- 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)
310
357
  # this is the amount of contiguous bytes available of the same subcluster type
311
- bytes_available = (sc_count + sc_index) << self.subcluster_bits
358
+ bytes_available = (sc_count + sc_index) << self.qcow2.subcluster_bits
312
359
 
313
360
  # account for the offset in the cluster
314
361
  read_count = min(bytes_available, bytes_needed) - offset_in_cluster
@@ -317,6 +364,58 @@ class QCow2(AlignedStream):
317
364
  length -= read_count
318
365
  offset += read_count
319
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
+
320
419
 
321
420
  class L2Table:
322
421
  """Convenience class for accessing the L2 table."""
@@ -338,42 +437,6 @@ class L2Table:
338
437
  return 0
339
438
 
340
439
 
341
- class QCow2Snapshot:
342
- """Wrapper class for snapshot table entries."""
343
-
344
- def __init__(self, qcow2: QCow2, offset: int):
345
- self.qcow2 = qcow2
346
- self.offset = offset
347
-
348
- self.qcow2.fh.seek(offset)
349
- self.header = c_qcow2.QCowSnapshotHeader(self.qcow2.fh)
350
-
351
- # Older versions may not have all the extra data fields
352
- # Instead of reading them manually, just pad the extra data to fit our struct
353
- extra_data = self.qcow2.fh.read(self.header.extra_data_size)
354
- self.extra = c_qcow2.QCowSnapshotExtraData(extra_data.ljust(len(c_qcow2.QCowSnapshotExtraData), b"\x00"))
355
-
356
- unknown_extra_size = self.header.extra_data_size - len(c_qcow2.QCowSnapshotExtraData)
357
- self.unknown_extra = self.qcow2.fh.read(unknown_extra_size) if unknown_extra_size > 0 else None
358
-
359
- self.id_str = self.qcow2.fh.read(self.header.id_str_size).decode()
360
- self.name = self.qcow2.fh.read(self.header.name_size).decode()
361
-
362
- self.entry_size = self.qcow2.fh.tell() - offset
363
-
364
- def open(self) -> QCow2:
365
- disk = copy.copy(self.qcow2)
366
- disk.l1_table = self.l1_table
367
- disk.seek(0)
368
- return disk
369
-
370
- @cached_property
371
- def l1_table(self) -> list[int]:
372
- # L1 table is usually relatively small, it can be at most 32MB on PB or EB size disks
373
- self.qcow2.fh.seek(self.header.l1_table_offset)
374
- return c_qcow2.uint64[self.header.l1_size](self.qcow2.fh)
375
-
376
-
377
440
  def offset_into_cluster(qcow2: QCow2, offset: int) -> int:
378
441
  return offset & (qcow2.cluster_size - 1)
379
442
 
@@ -471,16 +534,16 @@ def get_subcluster_range_type(
471
534
  sc_mask = (1 << sc_from) - 1
472
535
  if sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL:
473
536
  val = l2_bitmap | sc_mask # QCOW_OFLAG_SUB_ALLOC_RANGE(0, sc_from)
474
- return sc_type, ctz(val, 32) - sc_from
537
+ return sc_type, ctz(val) - sc_from
475
538
  if sc_type in ZERO_SUBCLUSTER_TYPES:
476
539
  val = (l2_bitmap | sc_mask) >> 32 # QCOW_OFLAG_SUB_ZERO_RANGE(0, sc_from)
477
- return sc_type, ctz(val, 32) - sc_from
540
+ return sc_type, ctz(val) - sc_from
478
541
  if sc_type in UNALLOCATED_SUBCLUSTER_TYPES:
479
542
  # We need to mask it with a 64bit mask because Python flips the sign bit
480
543
  inv_mask = ~sc_mask & ((1 << 64) - 1) # ~QCOW_OFLAG_SUB_ALLOC_RANGE(0, sc_from)
481
544
 
482
545
  val = ((l2_bitmap >> 32) | l2_bitmap) & inv_mask
483
- return sc_type, ctz(val, 32) - sc_from
546
+ return sc_type, ctz(val) - sc_from
484
547
 
485
548
  raise Error(f"Invalid subcluster type: {sc_type}")
486
549
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dissect.hypervisor
3
- Version: 3.19.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=tLVTtunhrvM6ELOGgwJtWNGBhRUWYRzeAHFbdkpTLZ4,21366
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.19.dev1.dist-info/licenses/COPYRIGHT,sha256=EOOoIwk_inOMUD4c1ylpzMtYLjGzmc-MLEVAEdLLr20,305
30
- dissect_hypervisor-3.19.dev1.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
31
- dissect_hypervisor-3.19.dev1.dist-info/METADATA,sha256=BE14yQzzJCRJ1aREgqWsywnHXiYa0H85iCpNtnRM8WA,3434
32
- dissect_hypervisor-3.19.dev1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
33
- dissect_hypervisor-3.19.dev1.dist-info/entry_points.txt,sha256=kW36GnJ3G1dSRYFL4r8Bj32Al_CkGqST3bdHvo3pyiY,120
34
- dissect_hypervisor-3.19.dev1.dist-info/top_level.txt,sha256=Mn-CQzEYsAbkxrUI0TnplHuXnGVKzxpDw_po_sXpvv4,8
35
- dissect_hypervisor-3.19.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,,