vdiskprobe 1.0.0__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.
vdiskprobe/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ # Copyright (c) 2026 IsArvin.
2
+ # This file is part of VDiskProbe. Please refer to the LICENCE file for licence information.
3
+
4
+ """Public package exports for VDiskProbe."""
5
+
6
+ from vdiskprobe.api import build_default_registry, detect, detect_path, detect_url, inspect_source
7
+ from vdiskprobe.detectors import DetectorRegistry, FormatDetector
8
+ from vdiskprobe.errors import VDiskProbeError, InvalidSourceError, RemoteSourceError
9
+ from vdiskprobe.info import ImageInfo
10
+ from vdiskprobe.log import configure_logging, get_logger
11
+ from vdiskprobe.source import ByteSource
12
+ from vdiskprobe.version import __version__
13
+
14
+ __all__ = [
15
+ "ByteSource",
16
+ "DetectorRegistry",
17
+ "FormatDetector",
18
+ "ImageInfo",
19
+ "VDiskProbeError",
20
+ "InvalidSourceError",
21
+ "RemoteSourceError",
22
+ "build_default_registry",
23
+ "configure_logging",
24
+ "detect",
25
+ "detect_path",
26
+ "detect_url",
27
+ "get_logger",
28
+ "inspect_source",
29
+ ]
vdiskprobe/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ # Copyright (c) 2026 IsArvin.
2
+ # This file is part of VDiskProbe. Please refer to the LICENCE file for licence information.
3
+
4
+ """Module entry point for ``python -m vdiskprobe``."""
5
+
6
+ from vdiskprobe.cli import main
7
+
8
+ if __name__ == "__main__":
9
+ raise SystemExit(main())
vdiskprobe/api.py ADDED
@@ -0,0 +1,119 @@
1
+ # Copyright (c) 2026 IsArvin.
2
+ # This file is part of VDiskProbe. Please refer to the LICENCE file for licence information.
3
+
4
+ """High-level convenience API for VDiskProbe."""
5
+
6
+ import os
7
+
8
+ from vdiskprobe.detectors import build_default_registry as _build_default_registry
9
+ from vdiskprobe.detectors.base import DetectorRegistry
10
+ from vdiskprobe.errors import InvalidSourceError
11
+ from vdiskprobe.http_source import HttpRangeReader, is_http_url
12
+ from vdiskprobe.info import ImageInfo
13
+ from vdiskprobe.source import ByteSource
14
+
15
+ _DEFAULT_REGISTRY = None
16
+ _HTTP_READER_CLASS = HttpRangeReader
17
+
18
+
19
+ def get_default_registry() -> DetectorRegistry:
20
+ """Return the process-wide default detector registry.
21
+
22
+ Returns
23
+ -------
24
+ DetectorRegistry
25
+ Detector registry instance populated with the built-in detectors.
26
+ """
27
+ global _DEFAULT_REGISTRY
28
+ if _DEFAULT_REGISTRY is None:
29
+ _DEFAULT_REGISTRY = _build_default_registry()
30
+ return _DEFAULT_REGISTRY
31
+
32
+
33
+ def inspect_source(source: object, registry: DetectorRegistry | None = None, name: str | None = None) -> ImageInfo:
34
+ """Inspect an arbitrary byte source.
35
+
36
+ Parameters
37
+ ----------
38
+ source : object
39
+ Seekable file-like object, random-access reader, or ``ByteSource``.
40
+ registry : DetectorRegistry, optional
41
+ Explicit detector registry. The built-in default registry is used
42
+ when omitted.
43
+ name : str, optional
44
+ Friendly source name shown in detection results.
45
+
46
+ Returns
47
+ -------
48
+ ImageInfo
49
+ Detected image information record.
50
+ """
51
+ byte_source = source if isinstance(source, ByteSource) else ByteSource(source, name=name)
52
+ active_registry = registry or get_default_registry()
53
+ return active_registry.detect(byte_source)
54
+
55
+
56
+ def detect(source: object, registry: DetectorRegistry | None = None, name: str | None = None) -> ImageInfo:
57
+ """Detect the image format of an arbitrary byte source.
58
+
59
+ Parameters
60
+ ----------
61
+ source : object
62
+ Seekable file-like object, random-access reader, or ``ByteSource``.
63
+ registry : DetectorRegistry, optional
64
+ Explicit detector registry.
65
+ name : str, optional
66
+ Friendly source name shown in the resulting ``ImageInfo``.
67
+
68
+ Returns
69
+ -------
70
+ ImageInfo
71
+ Detected image information record.
72
+ """
73
+ return inspect_source(source, registry=registry, name=name)
74
+
75
+
76
+ def detect_url(url: str, registry: DetectorRegistry | None = None) -> ImageInfo:
77
+ """Detect the image format of an HTTP or HTTPS URL."""
78
+ if not is_http_url(url):
79
+ # URL syntax only; this literal is never used for a network request.
80
+ # noinspection HttpUrlsUsage
81
+ raise InvalidSourceError("url must use http:// or https://")
82
+ reader = _HTTP_READER_CLASS(url)
83
+ return inspect_source(reader, registry=registry, name=url)
84
+
85
+
86
+ def detect_path(path: str | os.PathLike[str], registry: DetectorRegistry | None = None) -> ImageInfo:
87
+ """Detect the image format of a filesystem path or HTTP/HTTPS URL.
88
+
89
+ Parameters
90
+ ----------
91
+ path : str
92
+ Path or URL to an image file.
93
+ registry : DetectorRegistry, optional
94
+ Explicit detector registry.
95
+
96
+ Returns
97
+ -------
98
+ ImageInfo
99
+ Detected image information record.
100
+ """
101
+ # Both str and PathLike[str] are valid; PyCharm cannot resolve the generic union overload.
102
+ # noinspection PyTypeChecker
103
+ path = os.fspath(path)
104
+ if is_http_url(path):
105
+ return detect_url(path, registry=registry)
106
+ with open(path, "rb") as handle:
107
+ info = inspect_source(handle, registry=registry, name=path)
108
+ return info
109
+
110
+
111
+ def build_default_registry() -> DetectorRegistry:
112
+ """Create a new detector registry with the built-in detectors.
113
+
114
+ Returns
115
+ -------
116
+ DetectorRegistry
117
+ Fresh detector registry instance.
118
+ """
119
+ return _build_default_registry()
vdiskprobe/cli.py ADDED
@@ -0,0 +1,195 @@
1
+ # Copyright (c) 2026 IsArvin.
2
+ # This file is part of VDiskProbe. Please refer to the LICENCE file for licence information.
3
+
4
+ """Command-line interface for VDiskProbe."""
5
+
6
+ import argparse
7
+ from decimal import Decimal
8
+ import json
9
+ import logging
10
+ import os
11
+ import sys
12
+ from typing import TextIO
13
+
14
+ from vdiskprobe.info import ImageInfo
15
+ from vdiskprobe.api import detect_path, get_default_registry
16
+ from vdiskprobe.errors import VDiskProbeError
17
+ from vdiskprobe.log import configure_logging
18
+ from vdiskprobe.version import __version__
19
+
20
+ SIZE_UNITS = ("bytes", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB")
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ """Build the command-line argument parser.
25
+
26
+ Returns
27
+ -------
28
+ argparse.ArgumentParser
29
+ Configured parser instance.
30
+ """
31
+ parser = argparse.ArgumentParser(
32
+ prog="vdiskprobe", description="Detect VM image formats using VDiskProbe.", add_help=False
33
+ )
34
+ parser.add_argument("-h", "--help", action="help", help="Show this message and exit.")
35
+ parser.add_argument("path", nargs="?", help="Path or HTTP/HTTPS URL to the VM image.")
36
+ parser.add_argument("--json", action="store_true", help="Print JSON output.")
37
+ parser.add_argument("--list-formats", action="store_true", help="List built-in formats and exit.")
38
+ parser.add_argument(
39
+ "-V", "--version", action="version", version="%(prog)s {0}".format(__version__), help="Print version and exit."
40
+ )
41
+ parser.add_argument(
42
+ "-v", "--verbose", action="count", default=0, help="Increase log verbosity. Repeat for debug logs."
43
+ )
44
+ parser.add_argument("-q", "--quiet", action="store_true", help="Only log errors.")
45
+ return parser
46
+
47
+
48
+ def resolve_log_level(args: argparse.Namespace) -> int:
49
+ """Resolve the effective CLI log level.
50
+
51
+ Parameters
52
+ ----------
53
+ args : argparse.Namespace
54
+ Parsed command-line arguments.
55
+
56
+ Returns
57
+ -------
58
+ int
59
+ Logging level constant.
60
+ """
61
+ if args.quiet:
62
+ return logging.ERROR
63
+ if args.verbose >= 2:
64
+ return logging.DEBUG
65
+ if args.verbose == 1:
66
+ return logging.INFO
67
+ return logging.WARNING
68
+
69
+
70
+ def format_size(value: int) -> str:
71
+ """Format a byte count with an IEC unit and the exact byte value."""
72
+ size = int(value)
73
+ abs_size = abs(size)
74
+ unit_index = 0
75
+ try:
76
+ scaled = float(abs_size)
77
+ except OverflowError:
78
+ scaled = Decimal(abs_size)
79
+ while unit_index + 1 < len(SIZE_UNITS) and scaled >= 1024.0:
80
+ scaled /= 1024
81
+ unit_index += 1
82
+ if unit_index == 0:
83
+ readable = "{0} bytes".format(size)
84
+ else:
85
+ if scaled >= 100 or scaled == int(scaled):
86
+ number = format(scaled, ".0f")
87
+ elif scaled >= 10:
88
+ number = format(scaled, ".1f")
89
+ else:
90
+ number = format(scaled, ".2f")
91
+ if "." in number:
92
+ number = number.rstrip("0").rstrip(".")
93
+ if size < 0:
94
+ number = "-{0}".format(number)
95
+ readable = "{0} {1}".format(number, SIZE_UNITS[unit_index])
96
+ return "{0} ({1} bytes)".format(readable, size)
97
+
98
+
99
+ def print_formats(stream: TextIO) -> None:
100
+ """Write the built-in detector list to a stream.
101
+
102
+ Parameters
103
+ ----------
104
+ stream : object
105
+ Text output stream.
106
+ """
107
+ write = getattr(stream, "write")
108
+ registry = get_default_registry()
109
+ for metadata in registry.describe_formats():
110
+ aliases = metadata.get("aliases") or []
111
+ alias_suffix = ""
112
+ if aliases:
113
+ alias_suffix = " (aliases: {0})".format(", ".join(aliases))
114
+ mode = "read-only" if metadata.get("read_only") else "read-write"
115
+ line = "{0}: {1} [{2}]{3}".format(metadata["format"], metadata["description"], mode, alias_suffix)
116
+ write(line)
117
+ write(os.linesep)
118
+
119
+
120
+ def print_human(info: ImageInfo, stream: TextIO) -> None:
121
+ """Write a human-readable detection report.
122
+
123
+ Parameters
124
+ ----------
125
+ info : ImageInfo
126
+ Detected image information record.
127
+ stream : object
128
+ Text output stream.
129
+ """
130
+ write = getattr(stream, "write")
131
+ lines = [
132
+ "path: {0}".format(info.source_name or "<unknown>"),
133
+ "format: {0}".format(info.format),
134
+ "description: {0}".format(info.description),
135
+ ]
136
+ if info.virtual_size is not None:
137
+ lines.append("virtual_size: {0}".format(format_size(info.virtual_size)))
138
+ if info.file_size is not None:
139
+ lines.append("file_size: {0}".format(format_size(info.file_size)))
140
+ if info.disk_size is not None:
141
+ lines.append("disk_size: {0}".format(format_size(info.disk_size)))
142
+ if info.cluster_size is not None:
143
+ lines.append("cluster_size: {0}".format(format_size(info.cluster_size)))
144
+ lines.extend(
145
+ [
146
+ "read_only: {0}".format("yes" if info.read_only else "no"),
147
+ "confidence: {0:.2f}".format(info.confidence),
148
+ "detector: {0}".format(info.detector),
149
+ ]
150
+ )
151
+ if info.aliases:
152
+ lines.append("aliases: {0}".format(", ".join(info.aliases)))
153
+ if info.details:
154
+ for key in sorted(info.details):
155
+ lines.append("{0}: {1}".format(key, info.details[key]))
156
+ write(os.linesep.join(lines))
157
+ write(os.linesep)
158
+
159
+
160
+ def main(argv: list[str] | None = None) -> int:
161
+ """Run the VDiskProbe CLI.
162
+
163
+ Parameters
164
+ ----------
165
+ argv : list, optional
166
+ Command-line argument list. ``None`` means to use ``sys.argv``.
167
+
168
+ Returns
169
+ -------
170
+ int
171
+ Process exit code.
172
+ """
173
+ parser = build_parser()
174
+ args = parser.parse_args(argv)
175
+ configure_logging(resolve_log_level(args))
176
+ if args.list_formats:
177
+ print_formats(sys.stdout)
178
+ return 0
179
+ if not args.path:
180
+ parser.error("the following arguments are required: path")
181
+ try:
182
+ info = detect_path(args.path)
183
+ except OSError as exc:
184
+ sys.stderr.write("vdiskprobe: unable to read {0}: {1}{2}".format(args.path, exc, os.linesep))
185
+ return 1
186
+ except VDiskProbeError as exc:
187
+ sys.stderr.write("vdiskprobe: {0}{1}".format(exc, os.linesep))
188
+ return 1
189
+ if args.json:
190
+ payload = json.dumps(info.to_dict(), sort_keys=True, indent=2)
191
+ sys.stdout.write(payload)
192
+ sys.stdout.write(os.linesep)
193
+ else:
194
+ print_human(info, sys.stdout)
195
+ return 0
@@ -0,0 +1,12 @@
1
+ # Copyright (c) 2026 IsArvin.
2
+ # This file is part of VDiskProbe. Please refer to the LICENCE file for licence information.
3
+
4
+ """Detector registry and built-in formats."""
5
+
6
+ from vdiskprobe.detectors.base import DetectorRegistry, FormatDetector
7
+ from vdiskprobe.detectors.formats import BUILTIN_DETECTORS
8
+
9
+
10
+ def build_default_registry() -> DetectorRegistry:
11
+ """Return an independent registry containing the built-in detectors."""
12
+ return DetectorRegistry(BUILTIN_DETECTORS)
@@ -0,0 +1,296 @@
1
+ # Copyright (c) 2026 IsArvin.
2
+ # This file is part of VDiskProbe. Please refer to the LICENCE file for licence information.
3
+
4
+ """Parsing helpers for built-in image format detectors."""
5
+
6
+ import re
7
+ import struct
8
+ import sys
9
+ import uuid
10
+
11
+ from vdiskprobe.errors import RangeNotSupportedError
12
+ from vdiskprobe.source import ByteSource
13
+
14
+ CLOOP_HEADER_OFFSET = 128
15
+ CLOOP_MAX_BLOCK_SIZE = 64 * 1024 * 1024
16
+ DMG_SECTOR_COUNT_OFFSET = 492
17
+ SECTOR_SIZE = 512
18
+ VHDX_FILE_PARAMETERS_GUID = uuid.UUID("CAA16737-FA36-4D43-B3B6-33F0AA44E76B").bytes_le
19
+ VHDX_METADATA_REGION_GUID = uuid.UUID("8B7CA206-4790-4B9A-B8FE-575F050F886E").bytes_le
20
+ VHDX_REGION_TABLE_OFFSETS = (196608, 262144)
21
+ VHDX_VIRTUAL_DISK_SIZE_GUID = uuid.UUID("2FA54224-CD1B-4876-B211-5DBED83BF4B8").bytes_le
22
+ VHDX_MAX_TABLE_ENTRIES = 2047
23
+ VHDX_METADATA_TABLE_ENTRY_SIZE = 32
24
+ VMDK_DESCRIPTOR_READ_SIZE = 64 * 1024
25
+ QCOW_MAGIC = b"QFI\xfb"
26
+ QED_MAGIC = b"QED\x00"
27
+ VHDX_MAGIC = b"vhdxfile"
28
+ VDI_SIGNATURE = 3201962111
29
+ VMDK_MAGICS = (b"KDMV", b"COWD", b"VMDK")
30
+ VPC_COOKIES = (b"conectix", b"connectix")
31
+ VPC_DYNAMIC_COOKIE = b"cxsparse"
32
+ PARALLELS_MAGICS = (b"WithoutFreeSpace", b"WithouFreSpacExt")
33
+ BOCHS_MAGIC: bytes = b"Bochs Virtual HD Image"
34
+ BOCHS_SPARSE_MAGIC = 38177486
35
+ LUKS_MAGIC = b"LUKS\xba\xbe"
36
+ DMG_MAGIC = b"koly"
37
+ CLOOP_MAGIC = b"#!/bin/sh\n#V2.0 Format\nmodprobe cloop file=$0 && mount -r -t iso9660 /dev/cloop $1\n"
38
+ VMDK_EXTENT_RE = re.compile(b"^[ \\t]*(?:RW|RDONLY|NOACCESS)[ \\t]+(\\d+)[ \\t]+\\S+", re.IGNORECASE | re.MULTILINE)
39
+
40
+
41
+ def read_u32be(data: bytes) -> int:
42
+ """Read a big-endian unsigned 32-bit integer."""
43
+ return struct.unpack(">I", data)[0]
44
+
45
+
46
+ def read_u16be(data: bytes) -> int:
47
+ """Read a big-endian unsigned 16-bit integer."""
48
+ return struct.unpack(">H", data)[0]
49
+
50
+
51
+ def read_u64be(data: bytes) -> int:
52
+ """Read a big-endian unsigned 64-bit integer."""
53
+ return struct.unpack(">Q", data)[0]
54
+
55
+
56
+ def read_u8(data: bytes) -> int:
57
+ """Read an unsigned 8-bit integer."""
58
+ return struct.unpack("B", data)[0]
59
+
60
+
61
+ def read_u32le(data: bytes) -> int:
62
+ """Read a little-endian unsigned 32-bit integer."""
63
+ return struct.unpack("<I", data)[0]
64
+
65
+
66
+ def read_u64le(data: bytes) -> int:
67
+ """Read a little-endian unsigned 64-bit integer."""
68
+ return struct.unpack("<Q", data)[0]
69
+
70
+
71
+ def read_u16le(data: bytes) -> int:
72
+ """Read a little-endian unsigned 16-bit integer."""
73
+ return struct.unpack("<H", data)[0]
74
+
75
+
76
+ def nonzero(value: int | None) -> int | None:
77
+ """Return an integer value only when it carries useful metadata."""
78
+ if value:
79
+ return value
80
+ return None
81
+
82
+
83
+ def sector_bytes(sectors: int | None) -> int | None:
84
+ """Convert a sector count to bytes."""
85
+ if sectors is None:
86
+ return None
87
+ return int(str(sectors)) * SECTOR_SIZE
88
+
89
+
90
+ def is_power_of_two(value: int) -> bool:
91
+ """Return whether a positive integer is a power of two."""
92
+ return value > 0 and (not value & value - 1)
93
+
94
+
95
+ def is_valid_qed_cluster_size(value: int) -> bool:
96
+ """Return whether a QED cluster size is in the valid format range."""
97
+ return 4096 <= value <= 64 * 1024 * 1024 and is_power_of_two(value)
98
+
99
+
100
+ def looks_like_vmdk_descriptor(data: bytes) -> bool:
101
+ """Check whether text resembles a VMDK descriptor file."""
102
+ cleaned = data.replace(b"\x00", b"")
103
+ if b"# Disk DescriptorFile" in cleaned:
104
+ return True
105
+ return b"createType=" in cleaned and b"CID=" in cleaned
106
+
107
+
108
+ def startswith_any(data: bytes, candidates: tuple) -> bool:
109
+ """Check whether a string starts with any candidate prefix."""
110
+ for candidate in candidates:
111
+ if data.startswith(candidate):
112
+ return True
113
+ return False
114
+
115
+
116
+ def vmdk_descriptor_virtual_size(data: bytes) -> int | None:
117
+ """Extract a virtual disk size from VMDK descriptor extent lines."""
118
+ total_sectors = 0
119
+ digit_limit = sys.get_int_max_str_digits() or sys.int_info.default_max_str_digits
120
+ for match in VMDK_EXTENT_RE.finditer(data.replace(b"\x00", b"")):
121
+ digits = match.group(1)
122
+ if len(digits) > digit_limit:
123
+ return None
124
+ total_sectors += int(digits)
125
+ result = total_sectors * SECTOR_SIZE if total_sectors else None
126
+ if result is not None:
127
+ try:
128
+ if len(str(result)) > digit_limit:
129
+ return None
130
+ except ValueError:
131
+ return None
132
+ return result
133
+
134
+
135
+ def vmdk_sparse_sizes(header: bytes) -> tuple[int | None, int | None]:
136
+ """Extract VMDK sparse extent capacity and grain size when present."""
137
+ if header.startswith(b"KDMV") and len(header) >= 28:
138
+ return sector_bytes(nonzero(read_u64le(header[12:20]))), sector_bytes(nonzero(read_u64le(header[20:28])))
139
+ if header[:4] in (b"COWD", b"VMDK") and len(header) >= 20:
140
+ return sector_bytes(nonzero(read_u32le(header[12:16]))), sector_bytes(nonzero(read_u32le(header[16:20])))
141
+ return None, None
142
+
143
+
144
+ def vdi_header_sizes(header: bytes, major: int, minor: int) -> tuple[int | None, int | None]:
145
+ """Read size fields only from recognised VDI header layouts."""
146
+ if (major, minor) == (0, 2):
147
+ offset = 352
148
+ elif major == 1:
149
+ offset = 368
150
+ else:
151
+ return None, None
152
+ if len(header) < offset + 12:
153
+ return None, None
154
+ return nonzero(read_u64le(header[offset : offset + 8])), nonzero(read_u32le(header[offset + 8 : offset + 12]))
155
+
156
+
157
+ def vpc_footer_virtual_size(footer: bytes) -> int | None:
158
+ """Extract the current VHD size from a 512-byte footer."""
159
+ if len(footer) < 56:
160
+ return None
161
+ return nonzero(read_u64be(footer[48:56]))
162
+
163
+
164
+ def vpc_footer_disk_type(footer: bytes) -> int | None:
165
+ """Extract the VHD disk type code when available."""
166
+ if len(footer) < 64:
167
+ return None
168
+ return nonzero(read_u32be(footer[60:64]))
169
+
170
+
171
+ def vpc_subformat_from_type(disk_type: int | None) -> str | None:
172
+ """Map a VHD footer disk type to a readable subformat name."""
173
+ if disk_type == 2:
174
+ return "fixed"
175
+ if disk_type == 3:
176
+ return "dynamic"
177
+ if disk_type == 4:
178
+ return "differencing"
179
+ return None
180
+
181
+
182
+ def vpc_dynamic_cluster_size(dynamic_header: bytes) -> int | None:
183
+ """Extract the VHD dynamic block size when a dynamic header is present."""
184
+ if len(dynamic_header) < 36 or dynamic_header[:8] != VPC_DYNAMIC_COOKIE:
185
+ return None
186
+ return nonzero(read_u32be(dynamic_header[32:36]))
187
+
188
+
189
+ def read_optional_metadata(source: ByteSource, offset: int, size: int) -> bytes:
190
+ """Skip metadata probes outside the source or the platform's seek range."""
191
+ if offset > sys.maxsize - size:
192
+ return b""
193
+ source_size = source.get_size()
194
+ if source_size is not None and offset >= source_size:
195
+ return b""
196
+ try:
197
+ return source.read(offset, size)
198
+ except RangeNotSupportedError:
199
+ return b""
200
+
201
+
202
+ def vpc_dynamic_header(source: ByteSource, footer: bytes) -> bytes:
203
+ """Follow the VHD footer's dynamic-header pointer when available."""
204
+ offset = read_u64be(footer[16:24]) if len(footer) >= 24 else 0
205
+ if offset == 0xFFFFFFFFFFFFFFFF:
206
+ return b""
207
+ return read_optional_metadata(source, offset or 512, 36)
208
+
209
+
210
+ def vhdx_metadata_region(source: ByteSource) -> tuple[int, int] | None:
211
+ """Return the VHDX metadata region entry from either region table copy."""
212
+ for table_offset in VHDX_REGION_TABLE_OFFSETS:
213
+ header = read_optional_metadata(source, table_offset, 16)
214
+ if len(header) < 16 or header[:4] != b"regi":
215
+ continue
216
+ entry_count = read_u32le(header[8:12])
217
+ if entry_count > VHDX_MAX_TABLE_ENTRIES:
218
+ continue
219
+ entries = read_optional_metadata(source, table_offset + 16, entry_count * 32)
220
+ for index in range(entry_count):
221
+ entry = entries[index * 32 : (index + 1) * 32]
222
+ if len(entry) < 32 or entry[:16] != VHDX_METADATA_REGION_GUID:
223
+ continue
224
+ offset = read_u64le(entry[16:24])
225
+ length = read_u32le(entry[24:28])
226
+ if offset and length:
227
+ return offset, length
228
+ return None
229
+
230
+
231
+ def vhdx_metadata_sizes(source: ByteSource) -> tuple[int | None, int | None]:
232
+ """Extract VHDX virtual disk and payload block sizes from metadata."""
233
+ metadata_region = vhdx_metadata_region(source)
234
+ if metadata_region is None:
235
+ return None, None
236
+ metadata_offset, metadata_length = metadata_region
237
+ header = read_optional_metadata(source, metadata_offset, 32)
238
+ if len(header) < 32 or header[:8] != b"metadata":
239
+ return None, None
240
+ entry_count = read_u16le(header[10:12])
241
+ if entry_count > VHDX_MAX_TABLE_ENTRIES:
242
+ return None, None
243
+ virtual_size = None
244
+ cluster_size = None
245
+ entries = read_optional_metadata(source, metadata_offset + 32, entry_count * VHDX_METADATA_TABLE_ENTRY_SIZE)
246
+ for index in range(entry_count):
247
+ entry = entries[index * VHDX_METADATA_TABLE_ENTRY_SIZE : (index + 1) * VHDX_METADATA_TABLE_ENTRY_SIZE]
248
+ if len(entry) < VHDX_METADATA_TABLE_ENTRY_SIZE:
249
+ continue
250
+ item_id = entry[:16]
251
+ item_offset = read_u32le(entry[16:20])
252
+ item_length = read_u32le(entry[20:24])
253
+ if item_length == 0 or item_offset + item_length > metadata_length:
254
+ continue
255
+ if item_id == VHDX_FILE_PARAMETERS_GUID and item_length >= 4:
256
+ item = read_optional_metadata(source, metadata_offset + item_offset, 4)
257
+ if len(item) >= 4:
258
+ cluster_size = nonzero(read_u32le(item))
259
+ elif item_id == VHDX_VIRTUAL_DISK_SIZE_GUID and item_length >= 8:
260
+ item = read_optional_metadata(source, metadata_offset + item_offset, 8)
261
+ if len(item) >= 8:
262
+ virtual_size = nonzero(read_u64le(item))
263
+ return virtual_size, cluster_size
264
+
265
+
266
+ def bochs_header_sizes(header: bytes) -> tuple[int | None, int | None]:
267
+ """Extract Bochs growing-image size metadata."""
268
+ if len(header) < 84:
269
+ return None, None
270
+ version = read_u32le(header[64:68])
271
+ cluster_size = nonzero(read_u32le(header[80:84]))
272
+ virtual_size = None
273
+ if version == 65536 and len(header) >= 92:
274
+ virtual_size = nonzero(read_u64le(header[84:92]))
275
+ elif version == 131072 and len(header) >= 96:
276
+ virtual_size = nonzero(read_u64le(header[88:96]))
277
+ return virtual_size, cluster_size
278
+
279
+
280
+ def cloop_sizes(source: ByteSource) -> tuple[int | None, int | None]:
281
+ """Extract cloop block count and block size when the header is complete."""
282
+ header = read_optional_metadata(source, CLOOP_HEADER_OFFSET, 8)
283
+ if len(header) < 8:
284
+ return None, None
285
+ cluster_size = read_u32be(header[:4])
286
+ block_count = read_u32be(header[4:8])
287
+ if cluster_size == 0 or cluster_size % SECTOR_SIZE or cluster_size > CLOOP_MAX_BLOCK_SIZE:
288
+ return None, None
289
+ return int(block_count) * cluster_size, cluster_size
290
+
291
+
292
+ def cluster_size_from_bits(cluster_bits: int) -> int | None:
293
+ """Convert a qcow cluster bit count to bytes."""
294
+ if cluster_bits < 0 or cluster_bits > 63:
295
+ return None
296
+ return int(1) << cluster_bits