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 +29 -0
- vdiskprobe/__main__.py +9 -0
- vdiskprobe/api.py +119 -0
- vdiskprobe/cli.py +195 -0
- vdiskprobe/detectors/__init__.py +12 -0
- vdiskprobe/detectors/_format_helpers.py +296 -0
- vdiskprobe/detectors/base.py +200 -0
- vdiskprobe/detectors/formats.py +372 -0
- vdiskprobe/errors.py +20 -0
- vdiskprobe/http_source.py +324 -0
- vdiskprobe/info.py +149 -0
- vdiskprobe/log.py +95 -0
- vdiskprobe/source.py +284 -0
- vdiskprobe/version.py +8 -0
- vdiskprobe-1.0.0.data/data/share/doc/vdiskprobe/CHANGELOG.md +12 -0
- vdiskprobe-1.0.0.data/data/share/doc/vdiskprobe/LICENCE +21 -0
- vdiskprobe-1.0.0.data/data/share/doc/vdiskprobe/README.md +243 -0
- vdiskprobe-1.0.0.dist-info/METADATA +271 -0
- vdiskprobe-1.0.0.dist-info/RECORD +23 -0
- vdiskprobe-1.0.0.dist-info/WHEEL +5 -0
- vdiskprobe-1.0.0.dist-info/entry_points.txt +2 -0
- vdiskprobe-1.0.0.dist-info/licenses/LICENCE +21 -0
- vdiskprobe-1.0.0.dist-info/top_level.txt +1 -0
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
|