dissect.hypervisor 3.17.dev1__py3-none-any.whl → 3.17.dev3__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.
Files changed (29) hide show
  1. dissect/hypervisor/descriptor/c_hyperv.py +2 -0
  2. dissect/hypervisor/descriptor/hyperv.py +13 -15
  3. dissect/hypervisor/descriptor/ovf.py +9 -5
  4. dissect/hypervisor/descriptor/pvs.py +8 -3
  5. dissect/hypervisor/descriptor/vbox.py +8 -3
  6. dissect/hypervisor/descriptor/vmx.py +57 -47
  7. dissect/hypervisor/disk/c_hdd.py +1 -1
  8. dissect/hypervisor/disk/c_qcow2.py +4 -1
  9. dissect/hypervisor/disk/c_vdi.py +2 -0
  10. dissect/hypervisor/disk/c_vhd.py +2 -0
  11. dissect/hypervisor/disk/c_vhdx.py +2 -0
  12. dissect/hypervisor/disk/c_vmdk.py +2 -0
  13. dissect/hypervisor/disk/hdd.py +13 -13
  14. dissect/hypervisor/disk/qcow2.py +82 -72
  15. dissect/hypervisor/disk/vdi.py +5 -2
  16. dissect/hypervisor/disk/vhd.py +16 -13
  17. dissect/hypervisor/disk/vhdx.py +23 -17
  18. dissect/hypervisor/disk/vmdk.py +30 -30
  19. dissect/hypervisor/tools/envelope.py +5 -1
  20. dissect/hypervisor/util/envelope.py +12 -8
  21. dissect/hypervisor/util/vmtar.py +15 -8
  22. {dissect.hypervisor-3.17.dev1.dist-info → dissect.hypervisor-3.17.dev3.dist-info}/METADATA +2 -2
  23. dissect.hypervisor-3.17.dev3.dist-info/RECORD +34 -0
  24. {dissect.hypervisor-3.17.dev1.dist-info → dissect.hypervisor-3.17.dev3.dist-info}/WHEEL +1 -1
  25. dissect.hypervisor-3.17.dev1.dist-info/RECORD +0 -34
  26. {dissect.hypervisor-3.17.dev1.dist-info → dissect.hypervisor-3.17.dev3.dist-info}/COPYRIGHT +0 -0
  27. {dissect.hypervisor-3.17.dev1.dist-info → dissect.hypervisor-3.17.dev3.dist-info}/LICENSE +0 -0
  28. {dissect.hypervisor-3.17.dev1.dist-info → dissect.hypervisor-3.17.dev3.dist-info}/entry_points.txt +0 -0
  29. {dissect.hypervisor-3.17.dev1.dist-info → dissect.hypervisor-3.17.dev3.dist-info}/top_level.txt +0 -0
@@ -1,11 +1,13 @@
1
1
  # References:
2
2
  # - https://github.com/qemu/qemu/blob/master/block/qcow2.c
3
3
  # - https://github.com/qemu/qemu/blob/master/docs/interop/qcow2.txt
4
+ from __future__ import annotations
4
5
 
5
6
  import copy
6
7
  import zlib
7
8
  from functools import cached_property, lru_cache
8
9
  from io import BytesIO
10
+ from typing import TYPE_CHECKING, BinaryIO
9
11
 
10
12
  from dissect.util.stream import AlignedStream
11
13
 
@@ -21,6 +23,9 @@ from dissect.hypervisor.disk.c_qcow2 import (
21
23
  )
22
24
  from dissect.hypervisor.exceptions import Error, InvalidHeaderError
23
25
 
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Iterator
28
+
24
29
  try:
25
30
  import zstandard as zstd
26
31
 
@@ -45,7 +50,7 @@ class QCow2(AlignedStream):
45
50
  in all null bytes being read.
46
51
  """
47
52
 
48
- def __init__(self, fh, data_file=None, backing_file=None):
53
+ def __init__(self, fh: BinaryIO, data_file: BinaryIO | None = None, backing_file: BinaryIO | int | None = None):
49
54
  self.fh = fh
50
55
 
51
56
  self.header = c_qcow2.QCowHeader(fh)
@@ -120,7 +125,7 @@ class QCow2(AlignedStream):
120
125
 
121
126
  super().__init__(self.header.size)
122
127
 
123
- def _read_extensions(self):
128
+ def _read_extensions(self) -> None:
124
129
  start_offset = self.header.header_length
125
130
  end_offset = self.header.backing_file_offset or 1 << self.cluster_bits
126
131
 
@@ -135,7 +140,8 @@ class QCow2(AlignedStream):
135
140
 
136
141
  if ext.magic == c_qcow2.QCOW2_EXT_MAGIC_END:
137
142
  break
138
- elif ext.magic == c_qcow2.QCOW2_EXT_MAGIC_BACKING_FORMAT:
143
+
144
+ if ext.magic == c_qcow2.QCOW2_EXT_MAGIC_BACKING_FORMAT:
139
145
  self.backing_format = self.fh.read(ext.len).decode().upper()
140
146
  self.image_backing_format = self.backing_format.upper()
141
147
  elif ext.magic == c_qcow2.QCOW2_EXT_MAGIC_FEATURE_TABLE:
@@ -153,7 +159,7 @@ class QCow2(AlignedStream):
153
159
  offset += (ext.len + 7) & 0xFFFFFFF8
154
160
 
155
161
  @cached_property
156
- def snapshots(self):
162
+ def snapshots(self) -> list[QCow2Snapshot]:
157
163
  snapshots = []
158
164
 
159
165
  offset = self.header.snapshots_offset
@@ -164,46 +170,46 @@ class QCow2(AlignedStream):
164
170
  return snapshots
165
171
 
166
172
  @cached_property
167
- def l1_table(self):
173
+ def l1_table(self) -> list[int]:
168
174
  # L1 table is usually relatively small, it can be at most 32MB on PB or EB size disks
169
175
  self.fh.seek(self.header.l1_table_offset)
170
176
  return c_qcow2.uint64[self.header.l1_size](self.fh)
171
177
 
172
- def l2_table(self, l2_offset):
178
+ def l2_table(self, l2_offset: int) -> L2Table:
173
179
  return L2Table(self, l2_offset)
174
180
 
175
181
  @property
176
- def has_backing_file(self):
182
+ def has_backing_file(self) -> bool:
177
183
  return self.backing_file is not None
178
184
 
179
185
  @property
180
- def has_data_file(self):
186
+ def has_data_file(self) -> bool:
181
187
  return self.data_file != self.fh
182
188
 
183
189
  @property
184
- def has_subclusters(self):
190
+ def has_subclusters(self) -> bool:
185
191
  return bool(self.header.incompatible_features & c_qcow2.QCOW2_INCOMPAT_EXTL2)
186
192
 
187
- def _read(self, offset, length):
193
+ def _read(self, offset: int, length: int) -> bytes:
188
194
  result = []
189
195
 
190
- for sc_type, offset, run_offset, run_length in self._yield_runs(offset, length):
196
+ for sc_type, read_offset, run_offset, run_length in self._yield_runs(offset, length):
191
197
  unalloc_zeroed = sc_type in UNALLOCATED_SUBCLUSTER_TYPES and not self.has_backing_file
192
198
 
193
199
  if sc_type in ZERO_SUBCLUSTER_TYPES or unalloc_zeroed:
194
200
  result.append(b"\x00" * run_length)
195
201
  elif sc_type in UNALLOCATED_SUBCLUSTER_TYPES and self.has_backing_file:
196
- self.backing_file.seek(offset)
202
+ self.backing_file.seek(read_offset)
197
203
  result.append(self.backing_file.read(run_length))
198
204
  elif sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED:
199
- result.append(self._read_compressed(run_offset, offset, run_length))
205
+ result.append(self._read_compressed(run_offset, read_offset, run_length))
200
206
  elif sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL:
201
207
  self.data_file.seek(run_offset)
202
208
  result.append(self.data_file.read(run_length))
203
209
 
204
210
  return b"".join(result)
205
211
 
206
- def _read_compressed(self, cluster_descriptor, offset, length):
212
+ def _read_compressed(self, cluster_descriptor: int, offset: int, length: int) -> bytes:
207
213
  offset_in_cluster = offset_into_cluster(self, offset)
208
214
  coffset = cluster_descriptor & self.cluster_offset_mask
209
215
  nb_csectors = ((cluster_descriptor >> self.csize_shift) & self.csize_mask) + 1
@@ -217,11 +223,12 @@ class QCow2(AlignedStream):
217
223
 
218
224
  return decompressed[offset_in_cluster : offset_in_cluster + length]
219
225
 
220
- def _decompress(self, buf):
226
+ def _decompress(self, buf: bytes) -> bytes:
221
227
  if self.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZLIB:
222
228
  dctx = zlib.decompressobj(-12)
223
229
  return dctx.decompress(buf, self.cluster_size)
224
- elif self.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZSTD:
230
+
231
+ if self.compression_type == c_qcow2.QCOW2_COMPRESSION_TYPE_ZSTD:
225
232
  result = []
226
233
 
227
234
  dctx = zstd.ZstdDecompressor()
@@ -232,10 +239,10 @@ class QCow2(AlignedStream):
232
239
  break
233
240
  result.append(chunk)
234
241
  return b"".join(result)
235
- else:
236
- raise Error(f"Invalid compression type: {self.compression_type}")
237
242
 
238
- def _yield_runs(self, offset, length):
243
+ raise Error(f"Invalid compression type: {self.compression_type}")
244
+
245
+ def _yield_runs(self, offset: int, length: int) -> Iterator[tuple[QCow2SubclusterType, int, int, int]]:
239
246
  # reference: qcow2_get_host_offset
240
247
  while length > 0:
241
248
  sc_type = None
@@ -303,7 +310,7 @@ class QCow2(AlignedStream):
303
310
  class L2Table:
304
311
  """Convenience class for accessing the L2 table."""
305
312
 
306
- def __init__(self, qcow2, offset):
313
+ def __init__(self, qcow2: QCow2, offset: int):
307
314
  self.qcow2 = qcow2
308
315
  self.offset = offset
309
316
 
@@ -311,10 +318,10 @@ class L2Table:
311
318
  self.qcow2.fh.seek(offset)
312
319
  self._table = c_qcow2.uint64[l2_table_size](self.qcow2.fh)
313
320
 
314
- def entry(self, idx):
321
+ def entry(self, idx: int) -> int:
315
322
  return self._table[idx * self.qcow2._l2_entry_size // 8]
316
323
 
317
- def bitmap(self, idx):
324
+ def bitmap(self, idx: int) -> int:
318
325
  if self.qcow2.has_subclusters:
319
326
  return self._table[(idx * self.qcow2._l2_entry_size // 8) + 1]
320
327
  return 0
@@ -323,7 +330,7 @@ class L2Table:
323
330
  class QCow2Snapshot:
324
331
  """Wrapper class for snapshot table entries."""
325
332
 
326
- def __init__(self, qcow2, offset):
333
+ def __init__(self, qcow2: QCow2, offset: int):
327
334
  self.qcow2 = qcow2
328
335
  self.offset = offset
329
336
 
@@ -343,64 +350,65 @@ class QCow2Snapshot:
343
350
 
344
351
  self.entry_size = self.qcow2.fh.tell() - offset
345
352
 
346
- def open(self):
353
+ def open(self) -> QCow2:
347
354
  disk = copy.copy(self.qcow2)
348
355
  disk.l1_table = self.l1_table
349
356
  disk.seek(0)
350
357
  return disk
351
358
 
352
359
  @cached_property
353
- def l1_table(self):
360
+ def l1_table(self) -> list[int]:
354
361
  # L1 table is usually relatively small, it can be at most 32MB on PB or EB size disks
355
362
  self.qcow2.fh.seek(self.header.l1_table_offset)
356
363
  return c_qcow2.uint64[self.header.l1_size](self.qcow2.fh)
357
364
 
358
365
 
359
- def offset_into_cluster(qcow2, offset):
366
+ def offset_into_cluster(qcow2: QCow2, offset: int) -> int:
360
367
  return offset & (qcow2.cluster_size - 1)
361
368
 
362
369
 
363
- def offset_into_subcluster(qcow2, offset):
370
+ def offset_into_subcluster(qcow2: QCow2, offset: int) -> int:
364
371
  return offset & (qcow2.subcluster_size - 1)
365
372
 
366
373
 
367
- def size_to_clusters(qcow2, size):
374
+ def size_to_clusters(qcow2: QCow2, size: int) -> int:
368
375
  return (size + (qcow2.cluster_size - 1)) >> qcow2.cluster_bits
369
376
 
370
377
 
371
- def size_to_subclusters(qcow2, size):
378
+ def size_to_subclusters(qcow2: QCow2, size: int) -> int:
372
379
  return (size + (qcow2.subcluster_size - 1)) >> qcow2.subcluster_bits
373
380
 
374
381
 
375
- def offset_to_l1_index(qcow2, offset):
382
+ def offset_to_l1_index(qcow2: QCow2, offset: int) -> int:
376
383
  return offset >> (qcow2.l2_bits + qcow2.cluster_bits)
377
384
 
378
385
 
379
- def offset_to_l2_index(qcow2, offset):
386
+ def offset_to_l2_index(qcow2: QCow2, offset: int) -> int:
380
387
  return (offset >> qcow2.cluster_bits) & (qcow2.l2_size - 1)
381
388
 
382
389
 
383
- def offset_to_sc_index(qcow2, offset):
390
+ def offset_to_sc_index(qcow2: QCow2, offset: int) -> int:
384
391
  return (offset >> qcow2.subcluster_bits) & (qcow2.subclusters_per_cluster - 1)
385
392
 
386
393
 
387
- def get_cluster_type(qcow2, l2_entry):
394
+ def get_cluster_type(qcow2: QCow2, l2_entry: int) -> QCow2ClusterType:
388
395
  if l2_entry & c_qcow2.QCOW_OFLAG_COMPRESSED:
389
396
  return QCow2ClusterType.QCOW2_CLUSTER_COMPRESSED
390
- elif (l2_entry & c_qcow2.QCOW_OFLAG_ZERO) and not qcow2.has_subclusters:
397
+
398
+ if (l2_entry & c_qcow2.QCOW_OFLAG_ZERO) and not qcow2.has_subclusters:
391
399
  if l2_entry & c_qcow2.L2E_OFFSET_MASK:
392
400
  return QCow2ClusterType.QCOW2_CLUSTER_ZERO_ALLOC
393
401
  return QCow2ClusterType.QCOW2_CLUSTER_ZERO_PLAIN
394
- elif not l2_entry & c_qcow2.L2E_OFFSET_MASK:
402
+
403
+ if not l2_entry & c_qcow2.L2E_OFFSET_MASK:
395
404
  if qcow2.has_data_file and l2_entry & c_qcow2.QCOW_OFLAG_COPIED:
396
405
  return QCow2ClusterType.QCOW2_CLUSTER_NORMAL
397
- else:
398
- return QCow2ClusterType.QCOW2_CLUSTER_UNALLOCATED
399
- else:
400
- return QCow2ClusterType.QCOW2_CLUSTER_NORMAL
406
+ return QCow2ClusterType.QCOW2_CLUSTER_UNALLOCATED
401
407
 
408
+ return QCow2ClusterType.QCOW2_CLUSTER_NORMAL
402
409
 
403
- def get_subcluster_type(qcow2, l2_entry, l2_bitmap, sc_index):
410
+
411
+ def get_subcluster_type(qcow2: QCow2, l2_entry: int, l2_bitmap: int, sc_index: int) -> QCow2SubclusterType:
404
412
  c_type = get_cluster_type(qcow2, l2_entry)
405
413
 
406
414
  sc_alloc_mask = 1 << sc_index
@@ -409,40 +417,40 @@ def get_subcluster_type(qcow2, l2_entry, l2_bitmap, sc_index):
409
417
  if qcow2.has_subclusters:
410
418
  if c_type == QCow2ClusterType.QCOW2_CLUSTER_COMPRESSED:
411
419
  return QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED
412
- elif c_type == QCow2ClusterType.QCOW2_CLUSTER_NORMAL:
420
+ if c_type == QCow2ClusterType.QCOW2_CLUSTER_NORMAL:
413
421
  if (l2_bitmap >> 32) & l2_bitmap:
414
422
  return QCow2SubclusterType.QCOW2_SUBCLUSTER_INVALID
415
- elif l2_bitmap & sc_zero_mask: # QCOW_OFLAG_SUB_ZERO(sc_index)
423
+ if l2_bitmap & sc_zero_mask: # QCOW_OFLAG_SUB_ZERO(sc_index)
416
424
  return QCow2SubclusterType.QCOW2_SUBCLUSTER_ZERO_ALLOC
417
- elif l2_bitmap & sc_alloc_mask: # QCOW_OFLAG_SUB_ALLOC(sc_index)
425
+ if l2_bitmap & sc_alloc_mask: # QCOW_OFLAG_SUB_ALLOC(sc_index)
418
426
  return QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL
419
- else:
420
- return QCow2SubclusterType.QCOW2_SUBCLUSTER_UNALLOCATED_ALLOC
421
- elif c_type == QCow2ClusterType.QCOW2_CLUSTER_UNALLOCATED:
427
+ return QCow2SubclusterType.QCOW2_SUBCLUSTER_UNALLOCATED_ALLOC
428
+ if c_type == QCow2ClusterType.QCOW2_CLUSTER_UNALLOCATED:
422
429
  if l2_bitmap & ((1 << 32) - 1):
423
430
  return QCow2SubclusterType.QCOW2_SUBCLUSTER_INVALID
424
- elif l2_bitmap & sc_zero_mask: # QCOW_OFLAG_SUB_ZERO(sc_index)
431
+ if l2_bitmap & sc_zero_mask: # QCOW_OFLAG_SUB_ZERO(sc_index)
425
432
  return QCow2SubclusterType.QCOW2_SUBCLUSTER_ZERO_PLAIN
426
- else:
427
- return QCow2SubclusterType.QCOW2_SUBCLUSTER_UNALLOCATED_PLAIN
428
- else:
429
- raise Error(f"Invalid cluster type: {c_type}")
430
- else:
431
- if c_type == QCow2ClusterType.QCOW2_CLUSTER_COMPRESSED:
432
- return QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED
433
- elif c_type == QCow2ClusterType.QCOW2_CLUSTER_ZERO_PLAIN:
434
- return QCow2SubclusterType.QCOW2_SUBCLUSTER_ZERO_PLAIN
435
- elif c_type == QCow2ClusterType.QCOW2_CLUSTER_ZERO_ALLOC:
436
- return QCow2SubclusterType.QCOW2_SUBCLUSTER_ZERO_ALLOC
437
- elif c_type == QCow2ClusterType.QCOW2_CLUSTER_NORMAL:
438
- return QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL
439
- elif c_type == QCow2ClusterType.QCOW2_CLUSTER_UNALLOCATED:
440
433
  return QCow2SubclusterType.QCOW2_SUBCLUSTER_UNALLOCATED_PLAIN
441
- else:
442
- raise Error(f"Invalid cluster type: {c_type}")
443
434
 
435
+ raise Error(f"Invalid cluster type: {c_type}")
444
436
 
445
- def get_subcluster_range_type(qcow2, l2_entry, l2_bitmap, sc_from):
437
+ if c_type == QCow2ClusterType.QCOW2_CLUSTER_COMPRESSED:
438
+ return QCow2SubclusterType.QCOW2_SUBCLUSTER_COMPRESSED
439
+ if c_type == QCow2ClusterType.QCOW2_CLUSTER_ZERO_PLAIN:
440
+ return QCow2SubclusterType.QCOW2_SUBCLUSTER_ZERO_PLAIN
441
+ if c_type == QCow2ClusterType.QCOW2_CLUSTER_ZERO_ALLOC:
442
+ return QCow2SubclusterType.QCOW2_SUBCLUSTER_ZERO_ALLOC
443
+ if c_type == QCow2ClusterType.QCOW2_CLUSTER_NORMAL:
444
+ return QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL
445
+ if c_type == QCow2ClusterType.QCOW2_CLUSTER_UNALLOCATED:
446
+ return QCow2SubclusterType.QCOW2_SUBCLUSTER_UNALLOCATED_PLAIN
447
+
448
+ raise Error(f"Invalid cluster type: {c_type}")
449
+
450
+
451
+ def get_subcluster_range_type(
452
+ qcow2: QCow2, l2_entry: int, l2_bitmap: int, sc_from: int
453
+ ) -> tuple[QCow2SubclusterType, int]:
446
454
  sc_type = get_subcluster_type(qcow2, l2_entry, l2_bitmap, sc_from)
447
455
 
448
456
  # No subclusters, so count the entire cluster
@@ -452,21 +460,23 @@ def get_subcluster_range_type(qcow2, l2_entry, l2_bitmap, sc_from):
452
460
  sc_mask = (1 << sc_from) - 1
453
461
  if sc_type == QCow2SubclusterType.QCOW2_SUBCLUSTER_NORMAL:
454
462
  val = l2_bitmap | sc_mask # QCOW_OFLAG_SUB_ALLOC_RANGE(0, sc_from)
455
- return ctz(val, 32) - sc_from
456
- elif sc_type in ZERO_SUBCLUSTER_TYPES:
463
+ return sc_type, ctz(val, 32) - sc_from
464
+ if sc_type in ZERO_SUBCLUSTER_TYPES:
457
465
  val = (l2_bitmap | sc_mask) >> 32 # QCOW_OFLAG_SUB_ZERO_RANGE(0, sc_from)
458
- return ctz(val, 32) - sc_from
459
- elif sc_type in UNALLOCATED_SUBCLUSTER_TYPES:
466
+ return sc_type, ctz(val, 32) - sc_from
467
+ if sc_type in UNALLOCATED_SUBCLUSTER_TYPES:
460
468
  # We need to mask it with a 64bit mask because Python flips the sign bit
461
469
  inv_mask = ~sc_mask & ((1 << 64) - 1) # ~QCOW_OFLAG_SUB_ALLOC_RANGE(0, sc_from)
462
470
 
463
471
  val = ((l2_bitmap >> 32) | l2_bitmap) & inv_mask
464
- return ctz(val, 32) - sc_from
465
- else:
466
- raise Error(f"Invalid subcluster type: {sc_type}")
472
+ return sc_type, ctz(val, 32) - sc_from
473
+
474
+ raise Error(f"Invalid subcluster type: {sc_type}")
467
475
 
468
476
 
469
- def count_contiguous_subclusters(qcow2, nb_clusters, sc_index, l2_table, l2_index):
477
+ def count_contiguous_subclusters(
478
+ qcow2: QCow2, nb_clusters: int, sc_index: int, l2_table: L2Table, l2_index: int
479
+ ) -> int:
470
480
  count = 0
471
481
  expected_type = None
472
482
  expected_offset = None
@@ -1,4 +1,7 @@
1
+ from __future__ import annotations
2
+
1
3
  import array
4
+ from typing import BinaryIO
2
5
 
3
6
  from dissect.util.stream import AlignedStream
4
7
 
@@ -7,7 +10,7 @@ from dissect.hypervisor.exceptions import Error
7
10
 
8
11
 
9
12
  class VDI(AlignedStream):
10
- def __init__(self, fh, parent=None):
13
+ def __init__(self, fh: BinaryIO, parent: VDI | None = None):
11
14
  self.fh = fh
12
15
  self.parent = parent
13
16
  self.header = c_vdi.HeaderDescriptor(fh)
@@ -32,7 +35,7 @@ class VDI(AlignedStream):
32
35
  self.sector_size = self.header.SectorSize
33
36
  super().__init__(size=self.header.DiskSize)
34
37
 
35
- def _read(self, offset, length):
38
+ def _read(self, offset: int, length: int) -> bytes:
36
39
  block_idx, block_offset = divmod(offset, self.block_size)
37
40
 
38
41
  bytes_read = []
@@ -1,13 +1,16 @@
1
+ from __future__ import annotations
2
+
1
3
  import io
2
4
  import struct
3
5
  from functools import lru_cache
6
+ from typing import BinaryIO
4
7
 
5
8
  from dissect.util.stream import AlignedStream
6
9
 
7
10
  from dissect.hypervisor.disk.c_vhd import SECTOR_SIZE, c_vhd
8
11
 
9
12
 
10
- def read_footer(fh):
13
+ def read_footer(fh: BinaryIO) -> c_vhd.footer:
11
14
  fh.seek(-512, io.SEEK_END)
12
15
  footer = c_vhd.footer(fh)
13
16
  if not footer.features & 0x00000002:
@@ -19,7 +22,7 @@ def read_footer(fh):
19
22
 
20
23
  class VHD(AlignedStream):
21
24
  # Note: split VHD files are currently unsupported.
22
- def __init__(self, fh):
25
+ def __init__(self, fh: BinaryIO):
23
26
  self.fh = fh
24
27
 
25
28
  footer = read_footer(fh)
@@ -31,7 +34,7 @@ class VHD(AlignedStream):
31
34
 
32
35
  super().__init__(self.disk.size)
33
36
 
34
- def _read(self, offset, length):
37
+ def _read(self, offset: int, length: int) -> bytes:
35
38
  sector = offset // SECTOR_SIZE
36
39
  count = (length + SECTOR_SIZE - 1) // SECTOR_SIZE
37
40
 
@@ -39,23 +42,23 @@ class VHD(AlignedStream):
39
42
 
40
43
 
41
44
  class Disk:
42
- def __init__(self, fh, footer=None):
45
+ def __init__(self, fh: BinaryIO, footer: c_vhd.footer | None = None):
43
46
  self.fh = fh
44
47
  self.footer = footer if footer else read_footer(fh)
45
48
  self.size = self.footer.current_size
46
49
 
47
- def read_sectors(self, sector, count):
48
- raise NotImplementedError()
50
+ def read_sectors(self, sector: int, count: int) -> bytes:
51
+ raise NotImplementedError
49
52
 
50
53
 
51
54
  class FixedDisk(Disk):
52
- def read_sectors(self, sector, count):
55
+ def read_sectors(self, sector: int, count: int) -> bytes:
53
56
  self.fh.seek(sector * SECTOR_SIZE)
54
57
  return self.fh.read(count * SECTOR_SIZE)
55
58
 
56
59
 
57
60
  class DynamicDisk(Disk):
58
- def __init__(self, fh, footer=None):
61
+ def __init__(self, fh: BinaryIO, footer: c_vhd.footer | None = None):
59
62
  super().__init__(fh, footer)
60
63
  fh.seek(self.footer.data_offset)
61
64
  self.header = c_vhd.dynamic_header(fh)
@@ -66,7 +69,7 @@ class DynamicDisk(Disk):
66
69
  # Save bitmap size in sectors
67
70
  self._sector_bitmap_size = ((self._sectors_per_block // 8) + SECTOR_SIZE - 1) // SECTOR_SIZE
68
71
 
69
- def read_sectors(self, sector, count):
72
+ def read_sectors(self, sector: int, count: int) -> bytes:
70
73
  result = []
71
74
  while count > 0:
72
75
  block, offset = divmod(sector, self._sectors_per_block)
@@ -96,17 +99,17 @@ class BlockAllocationTable:
96
99
 
97
100
  ENTRY = struct.Struct(">I")
98
101
 
99
- def __init__(self, fh, offset, max_entries):
102
+ def __init__(self, fh: BinaryIO, offset: int, max_entries: int):
100
103
  self.fh = fh
101
104
  self.offset = offset
102
105
  self.max_entries = max_entries
103
106
 
104
107
  self.get = lru_cache(4096)(self.get)
105
108
 
106
- def get(self, block):
109
+ def get(self, block: int) -> int | None:
107
110
  # This could be improved by caching the entire BAT (or chunks if too large)
108
111
  if block + 1 > self.max_entries:
109
- raise ValueError("Invalid block {} (max block is {})".format(block, self.max_entries - 1))
112
+ raise ValueError(f"Invalid block {block} (max block is {self.max_entries - 1})")
110
113
 
111
114
  self.fh.seek(self.offset + block * 4)
112
115
  sector_offset = self.ENTRY.unpack(self.fh.read(4))[0]
@@ -114,5 +117,5 @@ class BlockAllocationTable:
114
117
  sector_offset = None
115
118
  return sector_offset
116
119
 
117
- def __getitem__(self, block):
120
+ def __getitem__(self, block: int) -> int | None:
118
121
  return self.get(block)
@@ -1,10 +1,13 @@
1
1
  # References:
2
2
  # - [MS-VHDX] https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-vhdx/83e061f8-f6e2-4de1-91bd-5d518a43d477
3
3
 
4
+ from __future__ import annotations
5
+
4
6
  import logging
5
7
  import os
6
8
  from functools import lru_cache
7
9
  from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any, BinaryIO, Callable, Final
8
11
  from uuid import UUID
9
12
 
10
13
  from dissect.util.stream import AlignedStream
@@ -25,6 +28,9 @@ from dissect.hypervisor.disk.c_vhdx import (
25
28
  )
26
29
  from dissect.hypervisor.exceptions import InvalidSignature, InvalidVirtualDisk
27
30
 
31
+ if TYPE_CHECKING:
32
+ from collections.abc import Iterator
33
+
28
34
  log = logging.getLogger(__name__)
29
35
  log.setLevel(os.getenv("DISSECT_LOG_VHDX", "CRITICAL"))
30
36
 
@@ -38,7 +44,7 @@ class VHDX(AlignedStream):
38
44
  the parent VHDX in the same directory, or the registered absolute directory.
39
45
  """
40
46
 
41
- def __init__(self, fh):
47
+ def __init__(self, fh: BinaryIO | Path | str):
42
48
  if hasattr(fh, "read"):
43
49
  name = getattr(fh, "name", None)
44
50
  path = Path(name) if name else None
@@ -85,7 +91,7 @@ class VHDX(AlignedStream):
85
91
  self._chunk_ratio = ((2**23) * self.sector_size) // self.block_size
86
92
 
87
93
  self.parent = None
88
- self.parent_locator = None
94
+ self.parent_locator: ParentLocator = None
89
95
  if self.has_parent:
90
96
  self.parent_locator = self.metadata.get(PARENT_LOCATOR_GUID)
91
97
  if self.parent_locator.type != VHDX_PARENT_LOCATOR_GUID:
@@ -97,7 +103,7 @@ class VHDX(AlignedStream):
97
103
 
98
104
  super().__init__(self.size)
99
105
 
100
- def read_sectors(self, sector, count):
106
+ def read_sectors(self, sector: int, count: int) -> bytes:
101
107
  log.debug("VHDX::read_sectors(0x%x, 0x%x)", sector, count)
102
108
  sectors_read = []
103
109
 
@@ -165,7 +171,7 @@ class VHDX(AlignedStream):
165
171
 
166
172
  return b"".join(sectors_read)
167
173
 
168
- def _read(self, offset, length):
174
+ def _read(self, offset: int, length: int) -> bytes:
169
175
  sector = offset // self.sector_size
170
176
  count = (length + self.sector_size - 1) // self.sector_size
171
177
 
@@ -173,7 +179,7 @@ class VHDX(AlignedStream):
173
179
 
174
180
 
175
181
  class RegionTable:
176
- def __init__(self, fh, offset):
182
+ def __init__(self, fh: BinaryIO, offset: int):
177
183
  self.fh = fh
178
184
  self.offset = offset
179
185
 
@@ -185,7 +191,7 @@ class RegionTable:
185
191
  self.entries = c_vhdx.region_table_entry[self.header.entry_count](fh)
186
192
  self.lookup = {UUID(bytes_le=e.guid): e for e in self.entries}
187
193
 
188
- def get(self, guid, required=True):
194
+ def get(self, guid: UUID, required: bool = True) -> c_vhdx.region_table_entry | None:
189
195
  data = self.lookup.get(guid)
190
196
  if not data and required:
191
197
  raise InvalidVirtualDisk(f"Missing required region: {guid}")
@@ -193,7 +199,7 @@ class RegionTable:
193
199
 
194
200
 
195
201
  class BlockAllocationTable:
196
- def __init__(self, vhdx, offset):
202
+ def __init__(self, vhdx: VHDX, offset: int):
197
203
  self.vhdx = vhdx
198
204
  self.offset = offset
199
205
  self.chunk_ratio = vhdx._chunk_ratio
@@ -208,7 +214,7 @@ class BlockAllocationTable:
208
214
 
209
215
  self.get = lru_cache(4096)(self.get)
210
216
 
211
- def get(self, entry):
217
+ def get(self, entry: int) -> c_vhdx.bat_entry:
212
218
  """Get a BAT entry."""
213
219
  if entry + 1 > self.entry_count:
214
220
  raise ValueError(f"Invalid entry for BAT lookup: {entry} (max entry is {self.entry_count - 1})")
@@ -216,13 +222,13 @@ class BlockAllocationTable:
216
222
  self.vhdx.fh.seek(self.offset + entry * 8)
217
223
  return c_vhdx.bat_entry(self.vhdx.fh)
218
224
 
219
- def pb(self, block):
225
+ def pb(self, block: int) -> c_vhdx.bat_entry:
220
226
  """Get a payload block entry for a given block."""
221
227
  # Calculate how many interleaved sector bitmap entries there must be for this block
222
228
  sb_entries = block // self.chunk_ratio
223
229
  return self.get(block + sb_entries)
224
230
 
225
- def sb(self, block):
231
+ def sb(self, block: int) -> c_vhdx.bat_entry:
226
232
  """Get a sector bitmap entry for a given block."""
227
233
  # Calculate how many interleaved sector bitmap entries there must be for this block
228
234
  num_sb = block // self.chunk_ratio
@@ -230,14 +236,14 @@ class BlockAllocationTable:
230
236
 
231
237
 
232
238
  class ParentLocator:
233
- def __init__(self, fh):
239
+ def __init__(self, fh: BinaryIO):
234
240
  self.fh = fh
235
241
  self.offset = fh.tell()
236
242
  self.header = c_vhdx.parent_locator_header(fh)
237
243
  self.type = UUID(bytes_le=self.header.locator_type)
238
244
  self._entries = c_vhdx.parent_locator_entry[self.header.key_value_count](fh)
239
245
 
240
- self.entries = {}
246
+ self.entries: dict[str, str] = {}
241
247
  for entry in self._entries:
242
248
  fh.seek(self.offset + entry.key_offset)
243
249
  key = fh.read(entry.key_length).decode("utf-16-le")
@@ -248,7 +254,7 @@ class ParentLocator:
248
254
 
249
255
 
250
256
  class MetadataTable:
251
- METADATA_MAP = {
257
+ METADATA_MAP: Final[dict[UUID, Callable[[BinaryIO], Any]]] = {
252
258
  FILE_PARAMETERS_GUID: c_vhdx.file_parameters,
253
259
  VIRTUAL_DISK_SIZE_GUID: c_vhdx.virtual_disk_size,
254
260
  VIRTUAL_DISK_ID_GUID: c_vhdx.virtual_disk_id,
@@ -257,7 +263,7 @@ class MetadataTable:
257
263
  PARENT_LOCATOR_GUID: ParentLocator,
258
264
  }
259
265
 
260
- def __init__(self, fh, offset, length):
266
+ def __init__(self, fh: BinaryIO, offset: int, length: int):
261
267
  self.fh = fh
262
268
  self.offset = offset
263
269
  self.length = length
@@ -277,14 +283,14 @@ class MetadataTable:
277
283
  value = self.METADATA_MAP[item_id](fh)
278
284
  self.lookup[item_id] = value
279
285
 
280
- def get(self, guid, required=True):
286
+ def get(self, guid: UUID, required: bool = True) -> Any | None:
281
287
  data = self.lookup.get(guid)
282
288
  if not data and required:
283
289
  raise InvalidVirtualDisk(f"Missing required region: {guid}")
284
290
  return data
285
291
 
286
292
 
287
- def _iter_partial_runs(bitmap, start_idx, length):
293
+ def _iter_partial_runs(bitmap: bytes, start_idx: int, length: int) -> Iterator[tuple[int, int]]:
288
294
  current_type = (bitmap[0] & (1 << start_idx)) >> start_idx
289
295
  current_count = 0
290
296
 
@@ -311,7 +317,7 @@ def _iter_partial_runs(bitmap, start_idx, length):
311
317
  yield (current_type, current_count)
312
318
 
313
319
 
314
- def open_parent(path, locator):
320
+ def open_parent(path: Path, locator: dict[str, str]) -> VHDX:
315
321
  try:
316
322
  filepath = path.joinpath(locator["relative_path"].replace("\\", "/"))
317
323
  if not filepath.exists():