ntfsdump 3.2.0__tar.gz → 3.3.0__tar.gz
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.
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/PKG-INFO +18 -2
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/README.md +17 -1
- ntfsdump-3.3.0/src/ntfsdump/__about__.py +2 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/cli.py +3 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/formats/detect.py +20 -4
- ntfsdump-3.3.0/src/ntfsdump/formats/vhd.py +110 -0
- ntfsdump-3.3.0/src/ntfsdump/sources/__init__.py +24 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/sources/resolver.py +47 -1
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/test_detect.py +24 -1
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/test_source.py +39 -1
- ntfsdump-3.3.0/tests/ntfsdump/test_vhd_real.py +98 -0
- ntfsdump-3.3.0/tests/ntfsdump/test_vhdx_chain.py +229 -0
- ntfsdump-3.3.0/tests/ntfsdump/test_vhdx_real.py +217 -0
- ntfsdump-3.2.0/src/ntfsdump/__about__.py +0 -2
- ntfsdump-3.2.0/src/ntfsdump/formats/vhd.py +0 -10
- ntfsdump-3.2.0/src/ntfsdump/sources/__init__.py +0 -9
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/.devcontainer/Dockerfile +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/.devcontainer/devcontainer.json +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/.devcontainer/postCreateCommands.fish +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/.github/workflows/publish-binary-pypi.yaml +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/.github/workflows/publish-release.yaml +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/.github/workflows/test.yaml +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/.gitignore +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/Dockerfile +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/LICENSE +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/docker-compose.yml +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/pyproject.toml +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/__init__.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/__main__.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/core.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/errors.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/formats/__init__.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/formats/base.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/formats/e01.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/formats/raw.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/formats/vmdk.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/image.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/logger.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/vmware/__init__.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/vmware/metadata.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/src/ntfsdump/volume.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/cache/.gitkeep +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/conftest.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/fixtures/vmware/SingleVM/SingleVM.vmx +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/fixtures/vmware/WindowsVM/WindowsVM.vmsd +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/fixtures/vmware/WindowsVM/WindowsVM.vmx +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/test__init__.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/test_cli.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/test_vmdk_chain.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/tests/ntfsdump/test_vmware.py +0 -0
- {ntfsdump-3.2.0 → ntfsdump-3.3.0}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ntfsdump
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.3.0
|
|
4
4
|
Summary: A command-line tool for efficiently extracting files, directories, and alternate data streams directly from NTFS image files.
|
|
5
5
|
Project-URL: Homepage, https://github.com/sumeshi/ntfsdump
|
|
6
6
|
Project-URL: Repository, https://github.com/sumeshi/ntfsdump
|
|
@@ -43,6 +43,7 @@ It supports common forensic image formats such as RAW, E01, VHD/VHDX, and VMDK t
|
|
|
43
43
|
- Lists and reads VMware snapshots (`--list-snapshots`, `--snapshot`)
|
|
44
44
|
- Selects a virtual disk when a VM has more than one (`--list-disks`, `--disk`)
|
|
45
45
|
- Reads split VMDK extents and VMDK snapshot/delta chains without merging them
|
|
46
|
+
- Reads Hyper-V checkpoint chains (`VHDX`/`AVHDX` differencing disks) without merging or converting them
|
|
46
47
|
- Read paths from standard input for integration with tools such as `ntfsfind`
|
|
47
48
|
- Use as a command-line tool or Python library
|
|
48
49
|
|
|
@@ -61,7 +62,7 @@ chmod +x ./ntfsdump
|
|
|
61
62
|
|
|
62
63
|
## Supported Input
|
|
63
64
|
|
|
64
|
-
- Image formats: `RAW`, `E01`, `VHD`, `VHDX
|
|
65
|
+
- Image formats: `RAW`, `E01`, `VHD`, `VHDX` (including Hyper-V `AVHDX` differencing disks), `VMDK` (auto-detected), plus VMware VM directories, VMX and VMSD
|
|
65
66
|
- File system: `NTFS`
|
|
66
67
|
- Partition tables: GPT and MBR are both supported
|
|
67
68
|
|
|
@@ -83,6 +84,7 @@ disk.raw
|
|
|
83
84
|
evidence.E01
|
|
84
85
|
disk.vhd
|
|
85
86
|
disk.vhdx
|
|
87
|
+
disk.avhdx
|
|
86
88
|
disk.vmdk
|
|
87
89
|
vm.vmsd
|
|
88
90
|
/path/to/vm/
|
|
@@ -195,6 +197,20 @@ ntfsfind '.*\.evtx' ./image.raw | ntfsdump -o ./dump ./image.raw
|
|
|
195
197
|
|
|
196
198
|
*Note: Any absolute path (starting with `/` or `\`) passed over stdin via tools like `ntfsfind` will automatically be cleaned, and the folder hierarchy will be rebuilt faithfully inside your local output directory (`./dump/Windows/System32/winevt/Logs/System.evtx`).*
|
|
197
199
|
|
|
200
|
+
#### Hyper-V checkpoints (VHDX/AVHDX)
|
|
201
|
+
|
|
202
|
+
`ntfsdump` can read Hyper-V checkpoint (differencing disk) chains directly. Both a base `VHDX` and an `AVHDX` differencing disk are auto-detected, and either can be passed as `SOURCE`:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
ntfsdump ./HyperVM/Disk_0.avhdx /$MFT
|
|
206
|
+
ntfsdump ./HyperVM/Disk_0.avhdx /Windows/System32/config/SYSTEM
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
- The parent chain is resolved automatically from the VHDX metadata (parent locator), so pointing at any disk in the chain reads the whole chain as one logical disk.
|
|
210
|
+
- Parent images are looked up next to the child image; absolute Windows parent paths are reduced to their basename.
|
|
211
|
+
- Images are never merged or converted — nothing is written to the source directory.
|
|
212
|
+
- Checkpoint listing (`--list-checkpoints`) is not yet available, and `-s` / `--snapshot` remains VMware-only.
|
|
213
|
+
|
|
198
214
|
|
|
199
215
|
### Python Module
|
|
200
216
|
|
|
@@ -27,6 +27,7 @@ It supports common forensic image formats such as RAW, E01, VHD/VHDX, and VMDK t
|
|
|
27
27
|
- Lists and reads VMware snapshots (`--list-snapshots`, `--snapshot`)
|
|
28
28
|
- Selects a virtual disk when a VM has more than one (`--list-disks`, `--disk`)
|
|
29
29
|
- Reads split VMDK extents and VMDK snapshot/delta chains without merging them
|
|
30
|
+
- Reads Hyper-V checkpoint chains (`VHDX`/`AVHDX` differencing disks) without merging or converting them
|
|
30
31
|
- Read paths from standard input for integration with tools such as `ntfsfind`
|
|
31
32
|
- Use as a command-line tool or Python library
|
|
32
33
|
|
|
@@ -45,7 +46,7 @@ chmod +x ./ntfsdump
|
|
|
45
46
|
|
|
46
47
|
## Supported Input
|
|
47
48
|
|
|
48
|
-
- Image formats: `RAW`, `E01`, `VHD`, `VHDX
|
|
49
|
+
- Image formats: `RAW`, `E01`, `VHD`, `VHDX` (including Hyper-V `AVHDX` differencing disks), `VMDK` (auto-detected), plus VMware VM directories, VMX and VMSD
|
|
49
50
|
- File system: `NTFS`
|
|
50
51
|
- Partition tables: GPT and MBR are both supported
|
|
51
52
|
|
|
@@ -67,6 +68,7 @@ disk.raw
|
|
|
67
68
|
evidence.E01
|
|
68
69
|
disk.vhd
|
|
69
70
|
disk.vhdx
|
|
71
|
+
disk.avhdx
|
|
70
72
|
disk.vmdk
|
|
71
73
|
vm.vmsd
|
|
72
74
|
/path/to/vm/
|
|
@@ -179,6 +181,20 @@ ntfsfind '.*\.evtx' ./image.raw | ntfsdump -o ./dump ./image.raw
|
|
|
179
181
|
|
|
180
182
|
*Note: Any absolute path (starting with `/` or `\`) passed over stdin via tools like `ntfsfind` will automatically be cleaned, and the folder hierarchy will be rebuilt faithfully inside your local output directory (`./dump/Windows/System32/winevt/Logs/System.evtx`).*
|
|
181
183
|
|
|
184
|
+
#### Hyper-V checkpoints (VHDX/AVHDX)
|
|
185
|
+
|
|
186
|
+
`ntfsdump` can read Hyper-V checkpoint (differencing disk) chains directly. Both a base `VHDX` and an `AVHDX` differencing disk are auto-detected, and either can be passed as `SOURCE`:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
ntfsdump ./HyperVM/Disk_0.avhdx /$MFT
|
|
190
|
+
ntfsdump ./HyperVM/Disk_0.avhdx /Windows/System32/config/SYSTEM
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- The parent chain is resolved automatically from the VHDX metadata (parent locator), so pointing at any disk in the chain reads the whole chain as one logical disk.
|
|
194
|
+
- Parent images are looked up next to the child image; absolute Windows parent paths are reduced to their basename.
|
|
195
|
+
- Images are never merged or converted — nothing is written to the source directory.
|
|
196
|
+
- Checkpoint listing (`--list-checkpoints`) is not yet available, and `-s` / `--snapshot` remains VMware-only.
|
|
197
|
+
|
|
182
198
|
|
|
183
199
|
### Python Module
|
|
184
200
|
|
|
@@ -8,10 +8,20 @@ import pyvmdk
|
|
|
8
8
|
from ntfsdump.errors import NtfsDumpError
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
VHDX_SIGNATURE = b'vhdxfile'
|
|
12
|
+
VHDX_SUFFIXES = ('.vhdx', '.avhdx')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_vhdx_file(path) -> bool:
|
|
16
|
+
"""Whether ``path`` starts with the VHDX file type identifier.
|
|
17
|
+
|
|
18
|
+
Hyper-V base VHDX and differencing AVHDX images share this signature; they
|
|
19
|
+
are distinguished by the presence of a parent locator / differential disk
|
|
20
|
+
type, which is handled by the VHDX chain resolver rather than detection.
|
|
21
|
+
"""
|
|
12
22
|
try:
|
|
13
|
-
with path.open('rb') as f:
|
|
14
|
-
return f.read(8) ==
|
|
23
|
+
with Path(path).open('rb') as f:
|
|
24
|
+
return f.read(8) == VHDX_SIGNATURE
|
|
15
25
|
except OSError:
|
|
16
26
|
return False
|
|
17
27
|
|
|
@@ -46,7 +56,7 @@ def detect_image_format(path) -> str:
|
|
|
46
56
|
|
|
47
57
|
try:
|
|
48
58
|
if pyvhdi.check_file_signature(str(p)):
|
|
49
|
-
return 'vhdx' if
|
|
59
|
+
return 'vhdx' if is_vhdx_file(p) else 'vhd'
|
|
50
60
|
except Exception:
|
|
51
61
|
pass
|
|
52
62
|
|
|
@@ -60,4 +70,10 @@ def detect_image_format(path) -> str:
|
|
|
60
70
|
if _is_vmdk_descriptor(p):
|
|
61
71
|
return 'vmdk'
|
|
62
72
|
|
|
73
|
+
# VHDX/AVHDX can also be recognized by their file signature (e.g. a .vhdx
|
|
74
|
+
# file with a non-standard extension), or by the .vhdx/.avhdx extension
|
|
75
|
+
# itself (e.g. a file whose signature check was inconclusive).
|
|
76
|
+
if p.suffix.lower() in VHDX_SUFFIXES or is_vhdx_file(p):
|
|
77
|
+
return 'vhdx'
|
|
78
|
+
|
|
63
79
|
return 'raw'
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
import pytsk3
|
|
8
|
+
import pyvhdi
|
|
9
|
+
|
|
10
|
+
from ntfsdump.errors import NtfsDumpError
|
|
11
|
+
from ntfsdump.formats.base import FormatHandler, BaseImgInfo
|
|
12
|
+
from ntfsdump.formats.detect import is_vhdx_file
|
|
13
|
+
|
|
14
|
+
_WINDOWS_ABSOLUTE_RE = re.compile(r'^[A-Za-z]:[\\/]')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class VhdChain:
|
|
19
|
+
"""A logical VHD/VHDX disk resolved from its parent (differencing) chain."""
|
|
20
|
+
|
|
21
|
+
handle: "pyvhdi.file"
|
|
22
|
+
handles: List["pyvhdi.file"] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parent_path(child: Path, parent_filename: str) -> Path:
|
|
26
|
+
"""Resolve a parent filename relative to the child image.
|
|
27
|
+
|
|
28
|
+
VHDX parent locators may contain absolute Windows paths (e.g.
|
|
29
|
+
``C:\\VMs\\Base\\disk.vhdx``). Those cannot be resolved on other
|
|
30
|
+
platforms, so only the basename is used and looked up next to the child.
|
|
31
|
+
"""
|
|
32
|
+
normalized = parent_filename.replace('\\', '/')
|
|
33
|
+
|
|
34
|
+
if _WINDOWS_ABSOLUTE_RE.match(parent_filename) or normalized.startswith('//'):
|
|
35
|
+
return child.parent / Path(normalized).name
|
|
36
|
+
|
|
37
|
+
parent = Path(normalized)
|
|
38
|
+
if parent.is_absolute():
|
|
39
|
+
return parent
|
|
40
|
+
return child.parent / parent
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _safe_close(handle) -> None:
|
|
44
|
+
try:
|
|
45
|
+
handle.close()
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def open_vhdx_chain(path: Path, _seen: Optional[List[Path]] = None) -> VhdChain:
|
|
51
|
+
"""Open ``path`` and recursively attach its parent, without merging.
|
|
52
|
+
|
|
53
|
+
Hyper-V checkpoints are differencing VHDX/AVHDX files that reference their
|
|
54
|
+
parent from the VHDX metadata (parent locator). The chain is linked with
|
|
55
|
+
``set_parent()`` so that unallocated blocks fall back to the parent image.
|
|
56
|
+
No merge, conversion or checkpoint operation is performed.
|
|
57
|
+
"""
|
|
58
|
+
path = Path(path)
|
|
59
|
+
seen = list(_seen or [])
|
|
60
|
+
resolved = path.resolve()
|
|
61
|
+
|
|
62
|
+
is_vhdx = is_vhdx_file(path)
|
|
63
|
+
family = 'VHDX' if is_vhdx else 'VHD'
|
|
64
|
+
parent_label = 'AVHDX' if is_vhdx else 'VHD'
|
|
65
|
+
|
|
66
|
+
if resolved in seen:
|
|
67
|
+
chain = ' -> '.join(p.name for p in seen + [resolved])
|
|
68
|
+
raise NtfsDumpError(
|
|
69
|
+
f"Circular {family} parent reference detected: {chain}"
|
|
70
|
+
)
|
|
71
|
+
seen.append(resolved)
|
|
72
|
+
|
|
73
|
+
handles: List["pyvhdi.file"] = []
|
|
74
|
+
try:
|
|
75
|
+
handle = pyvhdi.file()
|
|
76
|
+
handles.append(handle)
|
|
77
|
+
handle.open(str(path), mode='r')
|
|
78
|
+
|
|
79
|
+
parent_filename = handle.get_parent_filename()
|
|
80
|
+
if parent_filename:
|
|
81
|
+
parent = _parent_path(path, parent_filename)
|
|
82
|
+
if not parent.exists():
|
|
83
|
+
raise NtfsDumpError(
|
|
84
|
+
f"Parent {parent_label} was not found: {parent_filename}"
|
|
85
|
+
)
|
|
86
|
+
parent_chain = open_vhdx_chain(parent, seen)
|
|
87
|
+
handles.extend(parent_chain.handles)
|
|
88
|
+
try:
|
|
89
|
+
handle.set_parent(parent_chain.handle)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
raise NtfsDumpError(
|
|
92
|
+
f"Unable to attach parent {parent_label}: "
|
|
93
|
+
f"{parent_filename} ({exc})"
|
|
94
|
+
) from exc
|
|
95
|
+
except NtfsDumpError:
|
|
96
|
+
for item in handles:
|
|
97
|
+
_safe_close(item)
|
|
98
|
+
raise
|
|
99
|
+
except Exception as exc:
|
|
100
|
+
for item in handles:
|
|
101
|
+
_safe_close(item)
|
|
102
|
+
raise NtfsDumpError(f"Unable to open {family} image: {path} ({exc})") from exc
|
|
103
|
+
|
|
104
|
+
return VhdChain(handle=handles[0], handles=handles)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class VHDHandler(FormatHandler):
|
|
108
|
+
def get_img_info(self, path: str) -> pytsk3.Img_Info:
|
|
109
|
+
chain = open_vhdx_chain(Path(path))
|
|
110
|
+
return BaseImgInfo(chain.handle, keepalive=chain.handles)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
from ntfsdump.sources.resolver import (
|
|
3
|
+
EwfSource,
|
|
4
|
+
FileSource,
|
|
5
|
+
RawSource,
|
|
6
|
+
Source,
|
|
7
|
+
SourceResolver,
|
|
8
|
+
VhdSource,
|
|
9
|
+
VhdxSource,
|
|
10
|
+
VmdkSource,
|
|
11
|
+
VmwareSource,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"EwfSource",
|
|
16
|
+
"FileSource",
|
|
17
|
+
"RawSource",
|
|
18
|
+
"Source",
|
|
19
|
+
"SourceResolver",
|
|
20
|
+
"VhdSource",
|
|
21
|
+
"VhdxSource",
|
|
22
|
+
"VmdkSource",
|
|
23
|
+
"VmwareSource",
|
|
24
|
+
]
|
|
@@ -33,6 +33,49 @@ class FileSource(Source):
|
|
|
33
33
|
return handler.get_img_info(str(self.path))
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
class RawSource(FileSource):
|
|
37
|
+
"""A raw disk image."""
|
|
38
|
+
|
|
39
|
+
format = 'raw'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EwfSource(FileSource):
|
|
43
|
+
"""An EWF/E01 image (possibly split across multiple segments)."""
|
|
44
|
+
|
|
45
|
+
format = 'e01'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class VhdSource(FileSource):
|
|
49
|
+
"""A VHD image (conectix)."""
|
|
50
|
+
|
|
51
|
+
format = 'vhd'
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class VhdxSource(FileSource):
|
|
55
|
+
"""A VHDX/AVHDX source, chain-aware for Hyper-V differencing disks.
|
|
56
|
+
|
|
57
|
+
Mirrors :class:`VmdkSource`: the parent (checkpoint) chain is resolved
|
|
58
|
+
from the VHDX metadata without merging or converting the image.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
format = 'vhdx'
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class VmdkSource(FileSource):
|
|
65
|
+
"""A VMDK source, chain-aware for split extents and snapshot chains."""
|
|
66
|
+
|
|
67
|
+
format = 'vmdk'
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_SOURCE_CLASSES = {
|
|
71
|
+
'raw': RawSource,
|
|
72
|
+
'e01': EwfSource,
|
|
73
|
+
'vhd': VhdSource,
|
|
74
|
+
'vhdx': VhdxSource,
|
|
75
|
+
'vmdk': VmdkSource,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
36
79
|
class VmwareSource(Source):
|
|
37
80
|
"""A VMware VM directory, VMX or VMSD resolved into a logical disk."""
|
|
38
81
|
|
|
@@ -109,7 +152,10 @@ class SourceResolver:
|
|
|
109
152
|
)
|
|
110
153
|
|
|
111
154
|
resolved_format = self._resolve_format(path, image_format)
|
|
112
|
-
|
|
155
|
+
source_cls = _SOURCE_CLASSES.get(resolved_format)
|
|
156
|
+
if source_cls is None:
|
|
157
|
+
raise NtfsDumpError(f"Unknown image format: {resolved_format}")
|
|
158
|
+
return source_cls(path, resolved_format)
|
|
113
159
|
|
|
114
160
|
def _resolve_format(self, path: Path, image_format: Optional[str]) -> str:
|
|
115
161
|
if image_format is not None:
|
|
@@ -5,7 +5,7 @@ import pytest
|
|
|
5
5
|
|
|
6
6
|
from ntfsdump.errors import NtfsDumpError
|
|
7
7
|
from ntfsdump.formats import get_format_handler
|
|
8
|
-
from ntfsdump.formats.detect import detect_image_format
|
|
8
|
+
from ntfsdump.formats.detect import detect_image_format, is_vhdx_file
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def _write(path: Path, data: bytes) -> Path:
|
|
@@ -36,6 +36,29 @@ def test_detect_vhdx(tmp_path: Path):
|
|
|
36
36
|
assert detect_image_format(image) == 'vhdx'
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
def test_detect_vhdx_by_signature_only(tmp_path: Path):
|
|
40
|
+
# Signature wins even without a .vhdx/.avhdx extension.
|
|
41
|
+
image = _write(tmp_path / 'disk.bin', b'vhdxfile' + b'\x00' * 8192)
|
|
42
|
+
assert detect_image_format(image) == 'vhdx'
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_detect_avhdx_by_extension_only(tmp_path: Path):
|
|
46
|
+
# Extension is accepted when the signature check is inconclusive.
|
|
47
|
+
image = _write(tmp_path / 'Disk_0.avhdx', b'\x00' * 8192)
|
|
48
|
+
assert detect_image_format(image) == 'vhdx'
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_detect_avhdx(tmp_path: Path):
|
|
52
|
+
image = _write(tmp_path / 'Disk_0.avhdx', b'vhdxfile' + b'\x00' * 8192)
|
|
53
|
+
assert detect_image_format(image) == 'vhdx'
|
|
54
|
+
assert is_vhdx_file(image) is True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_is_vhdx_file_false_for_vhd(tmp_path: Path):
|
|
58
|
+
image = _write(tmp_path / 'disk.vhd', b'conectix' + b'\x00' * 512)
|
|
59
|
+
assert is_vhdx_file(image) is False
|
|
60
|
+
|
|
61
|
+
|
|
39
62
|
def test_detect_vmdk_descriptor(tmp_path: Path):
|
|
40
63
|
image = _write(
|
|
41
64
|
tmp_path / 'Windows.vmdk',
|
|
@@ -5,7 +5,16 @@ import pytest
|
|
|
5
5
|
|
|
6
6
|
from ntfsdump.errors import NtfsDumpError
|
|
7
7
|
from ntfsdump.image import is_ntfs_volume_description
|
|
8
|
-
from ntfsdump.sources import
|
|
8
|
+
from ntfsdump.sources import (
|
|
9
|
+
EwfSource,
|
|
10
|
+
FileSource,
|
|
11
|
+
RawSource,
|
|
12
|
+
SourceResolver,
|
|
13
|
+
VhdSource,
|
|
14
|
+
VhdxSource,
|
|
15
|
+
VmdkSource,
|
|
16
|
+
VmwareSource,
|
|
17
|
+
)
|
|
9
18
|
|
|
10
19
|
SECTOR = 512
|
|
11
20
|
FIXTURES = Path(__file__).parent / 'fixtures' / 'vmware'
|
|
@@ -149,3 +158,32 @@ def test_snapshot_option_rejected_for_plain_file(tmp_path: Path):
|
|
|
149
158
|
|
|
150
159
|
with pytest.raises(NtfsDumpError, match="VMware SOURCE"):
|
|
151
160
|
SourceResolver().resolve(image, snapshot='1')
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.mark.parametrize('image_format, expected', [
|
|
164
|
+
('raw', RawSource),
|
|
165
|
+
('e01', EwfSource),
|
|
166
|
+
('vhd', VhdSource),
|
|
167
|
+
('vhdx', VhdxSource),
|
|
168
|
+
('vmdk', VmdkSource),
|
|
169
|
+
])
|
|
170
|
+
def test_resolver_returns_format_specific_source(
|
|
171
|
+
tmp_path: Path, image_format: str, expected
|
|
172
|
+
):
|
|
173
|
+
image = tmp_path / 'image.bin'
|
|
174
|
+
image.write_bytes(b'\x00' * 64)
|
|
175
|
+
|
|
176
|
+
source = SourceResolver().resolve(image, image_format=image_format)
|
|
177
|
+
|
|
178
|
+
assert type(source) is expected
|
|
179
|
+
assert source.format == image_format
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_resolver_detects_avhdx_as_vhdx_source(tmp_path: Path):
|
|
183
|
+
image = tmp_path / 'Disk_0.avhdx'
|
|
184
|
+
image.write_bytes(b'vhdxfile' + b'\x00' * 64)
|
|
185
|
+
|
|
186
|
+
source = SourceResolver().resolve(image)
|
|
187
|
+
|
|
188
|
+
assert isinstance(source, VhdxSource)
|
|
189
|
+
assert source.format == 'vhdx'
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
"""End-to-end tests using real pyvhdi handles (no mocks).
|
|
3
|
+
|
|
4
|
+
Minimal dynamic/differential VHD images are crafted on the fly so that the
|
|
5
|
+
differencing-disk parent fallback is exercised through the real libvhdi
|
|
6
|
+
implementation.
|
|
7
|
+
"""
|
|
8
|
+
import struct
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SECTOR = 512
|
|
13
|
+
BLOCK_SIZE = 2 * 1024 * 1024
|
|
14
|
+
MAX_ENTRIES = 1
|
|
15
|
+
MARKER_OFFSET = 64 * SECTOR
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _vhd_checksum(data: bytes) -> int:
|
|
19
|
+
return (~sum(data)) & 0xFFFFFFFF
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_footer(disk_type: int, disk_uuid: uuid.UUID) -> bytes:
|
|
23
|
+
f = bytearray(512)
|
|
24
|
+
f[0:8] = b'conectix'
|
|
25
|
+
struct.pack_into('>IIQ', f, 8, 0, 0x00010000, 512)
|
|
26
|
+
f[28:32] = b'ntfs'
|
|
27
|
+
struct.pack_into('>II', f, 32, 0x00010000, 0x00000002)
|
|
28
|
+
struct.pack_into('>QQ', f, 40, BLOCK_SIZE, BLOCK_SIZE)
|
|
29
|
+
struct.pack_into('>HBBH', f, 56, 0xFF, 16, 63, 0)
|
|
30
|
+
struct.pack_into('>I', f, 60, disk_type) # 3=dynamic 4=differencing
|
|
31
|
+
f[68:84] = disk_uuid.bytes
|
|
32
|
+
struct.pack_into('>I', f, 64, _vhd_checksum(bytes(f)))
|
|
33
|
+
return bytes(f)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _make_dynamic_header(
|
|
37
|
+
parent_uuid: 'uuid.UUID | None' = None,
|
|
38
|
+
parent_name: str = '',
|
|
39
|
+
) -> bytes:
|
|
40
|
+
h = bytearray(1024)
|
|
41
|
+
h[0:8] = b'cxsparse'
|
|
42
|
+
struct.pack_into('>Q', h, 8, 0xFFFFFFFFFFFFFFFF)
|
|
43
|
+
struct.pack_into('>Q', h, 16, 3 * SECTOR)
|
|
44
|
+
struct.pack_into('>II', h, 24, 0x00010000, MAX_ENTRIES)
|
|
45
|
+
struct.pack_into('>I', h, 32, BLOCK_SIZE)
|
|
46
|
+
if parent_uuid is not None:
|
|
47
|
+
h[40:56] = parent_uuid.bytes
|
|
48
|
+
if parent_name:
|
|
49
|
+
encoded = parent_name.encode('utf-16-be')
|
|
50
|
+
h[64:64 + len(encoded)] = encoded
|
|
51
|
+
h[576:580] = b'W2ku'
|
|
52
|
+
struct.pack_into('>I', h, 36, _vhd_checksum(bytes(h)))
|
|
53
|
+
return bytes(h)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _make_vhd(
|
|
57
|
+
path: Path,
|
|
58
|
+
disk_type: int,
|
|
59
|
+
disk_uuid: uuid.UUID,
|
|
60
|
+
bat_entry: int,
|
|
61
|
+
block_data: bytes,
|
|
62
|
+
parent_uuid: 'uuid.UUID | None' = None,
|
|
63
|
+
parent_name: str = '',
|
|
64
|
+
) -> None:
|
|
65
|
+
path.write_bytes(
|
|
66
|
+
_make_footer(disk_type, disk_uuid)
|
|
67
|
+
+ _make_dynamic_header(parent_uuid, parent_name)
|
|
68
|
+
+ struct.pack('>I', bat_entry)
|
|
69
|
+
+ b'\x00' * (SECTOR - 4)
|
|
70
|
+
+ b'\xff' * SECTOR
|
|
71
|
+
+ block_data
|
|
72
|
+
+ _make_footer(disk_type, disk_uuid)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_real_vhd_differential_parent_fallback(tmp_path: Path):
|
|
77
|
+
from ntfsdump.formats.vhd import VHDHandler
|
|
78
|
+
|
|
79
|
+
base_uuid = uuid.uuid4()
|
|
80
|
+
base_block = bytearray(BLOCK_SIZE)
|
|
81
|
+
base_block[MARKER_OFFSET:MARKER_OFFSET + 10] = b'PARENTDATA'
|
|
82
|
+
_make_vhd(
|
|
83
|
+
tmp_path / 'base.vhd', 3, base_uuid, 4, bytes(base_block),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# The differencing disk has its block unmapped (BAT = 0xffffffff), so any
|
|
87
|
+
# read must fall back to the parent through set_parent().
|
|
88
|
+
_make_vhd(
|
|
89
|
+
tmp_path / 'child.vhd', 4, uuid.uuid4(), 0xFFFFFFFF, b'\x00' * BLOCK_SIZE,
|
|
90
|
+
parent_uuid=base_uuid, parent_name='base.vhd',
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
img_info = VHDHandler().get_img_info(str(tmp_path / 'child.vhd'))
|
|
94
|
+
try:
|
|
95
|
+
assert img_info.get_size() == BLOCK_SIZE
|
|
96
|
+
assert img_info.read(MARKER_OFFSET, 10) == b'PARENTDATA'
|
|
97
|
+
finally:
|
|
98
|
+
img_info.close()
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import ntfsdump.formats.vhd as vhd
|
|
7
|
+
from ntfsdump.errors import NtfsDumpError
|
|
8
|
+
from ntfsdump.formats.vhd import VHDHandler, open_vhdx_chain
|
|
9
|
+
|
|
10
|
+
VHDX_HEADER = b'vhdxfile' + b'\x00' * 64
|
|
11
|
+
VHD_HEADER = b'\x00' * 64
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakeVhdiFile:
|
|
15
|
+
files = {}
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self._rec = None
|
|
19
|
+
|
|
20
|
+
def open(self, path, mode='r'):
|
|
21
|
+
self._rec = self.files[str(Path(path).resolve())]
|
|
22
|
+
|
|
23
|
+
def get_parent_filename(self):
|
|
24
|
+
return self._rec.get("parent_filename")
|
|
25
|
+
|
|
26
|
+
def get_identifier(self):
|
|
27
|
+
return self._rec.get("identifier")
|
|
28
|
+
|
|
29
|
+
def get_parent_identifier(self):
|
|
30
|
+
return self._rec.get("parent_identifier")
|
|
31
|
+
|
|
32
|
+
def get_media_size(self):
|
|
33
|
+
return self._rec.get("media_size", 0)
|
|
34
|
+
|
|
35
|
+
def set_parent(self, parent):
|
|
36
|
+
if self._rec.get("parent_identifier") != parent.get_identifier():
|
|
37
|
+
raise OSError("mismatch in parent identifier")
|
|
38
|
+
self._parent = parent
|
|
39
|
+
|
|
40
|
+
def seek(self, offset):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
def read(self, size):
|
|
44
|
+
return b"\x00" * size
|
|
45
|
+
|
|
46
|
+
def close(self):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def fake_vhdi(monkeypatch):
|
|
52
|
+
FakeVhdiFile.files = {}
|
|
53
|
+
monkeypatch.setattr(vhd.pyvhdi, 'file', FakeVhdiFile)
|
|
54
|
+
yield FakeVhdiFile.files
|
|
55
|
+
FakeVhdiFile.files = {}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _make(
|
|
59
|
+
path: Path,
|
|
60
|
+
registry: dict,
|
|
61
|
+
*,
|
|
62
|
+
parent_filename=None,
|
|
63
|
+
media_size=0,
|
|
64
|
+
identifier=None,
|
|
65
|
+
parent_identifier=None,
|
|
66
|
+
vhdx=True,
|
|
67
|
+
) -> Path:
|
|
68
|
+
path.write_bytes(VHDX_HEADER if vhdx else VHD_HEADER)
|
|
69
|
+
registry[str(path.resolve())] = {
|
|
70
|
+
"parent_filename": parent_filename,
|
|
71
|
+
"identifier": identifier,
|
|
72
|
+
"parent_identifier": parent_identifier,
|
|
73
|
+
"media_size": media_size,
|
|
74
|
+
}
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_base_only(tmp_path: Path, fake_vhdi):
|
|
79
|
+
base = _make(tmp_path / 'base.vhdx', fake_vhdi, media_size=4096)
|
|
80
|
+
chain = open_vhdx_chain(base)
|
|
81
|
+
|
|
82
|
+
assert len(chain.handles) == 1
|
|
83
|
+
assert chain.handle.get_media_size() == 4096
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_base_plus_child(tmp_path: Path, fake_vhdi):
|
|
87
|
+
_make(
|
|
88
|
+
tmp_path / 'base.vhdx', fake_vhdi,
|
|
89
|
+
media_size=4096, identifier='base-id',
|
|
90
|
+
)
|
|
91
|
+
child = _make(
|
|
92
|
+
tmp_path / 'base-000001.avhdx', fake_vhdi,
|
|
93
|
+
parent_filename='base.vhdx',
|
|
94
|
+
media_size=4096, identifier='child-id', parent_identifier='base-id',
|
|
95
|
+
)
|
|
96
|
+
chain = open_vhdx_chain(child)
|
|
97
|
+
|
|
98
|
+
assert len(chain.handles) == 2
|
|
99
|
+
assert chain.handle.get_media_size() == 4096
|
|
100
|
+
# The child must actually be linked to the parent handle so that
|
|
101
|
+
# unallocated blocks fall back to it.
|
|
102
|
+
assert chain.handle._parent is chain.handles[1]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_base_plus_two_levels(tmp_path: Path, fake_vhdi):
|
|
106
|
+
_make(
|
|
107
|
+
tmp_path / 'base.vhdx', fake_vhdi,
|
|
108
|
+
media_size=4096, identifier='base-id',
|
|
109
|
+
)
|
|
110
|
+
_make(
|
|
111
|
+
tmp_path / 'base-000001.avhdx', fake_vhdi,
|
|
112
|
+
parent_filename='base.vhdx',
|
|
113
|
+
media_size=4096, identifier='c1-id', parent_identifier='base-id',
|
|
114
|
+
)
|
|
115
|
+
c2 = _make(
|
|
116
|
+
tmp_path / 'base-000002.avhdx', fake_vhdi,
|
|
117
|
+
parent_filename='base-000001.avhdx',
|
|
118
|
+
media_size=4096, identifier='c2-id', parent_identifier='c1-id',
|
|
119
|
+
)
|
|
120
|
+
chain = open_vhdx_chain(c2)
|
|
121
|
+
|
|
122
|
+
assert len(chain.handles) == 3
|
|
123
|
+
assert chain.handle._parent is chain.handles[1]
|
|
124
|
+
assert chain.handles[1]._parent is chain.handles[2]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_missing_parent_raises(tmp_path: Path, fake_vhdi):
|
|
128
|
+
child = _make(
|
|
129
|
+
tmp_path / 'base-000001.avhdx', fake_vhdi,
|
|
130
|
+
parent_filename='missing.vhdx',
|
|
131
|
+
media_size=4096, identifier='child-id', parent_identifier='base-id',
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
with pytest.raises(
|
|
135
|
+
NtfsDumpError, match="Parent AVHDX was not found: missing.vhdx"
|
|
136
|
+
):
|
|
137
|
+
open_vhdx_chain(child)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_circular_parent_raises(tmp_path: Path, fake_vhdi):
|
|
141
|
+
_make(
|
|
142
|
+
tmp_path / 'a.avhdx', fake_vhdi,
|
|
143
|
+
parent_filename='b.avhdx',
|
|
144
|
+
media_size=4096, identifier='a-id', parent_identifier='b-id',
|
|
145
|
+
)
|
|
146
|
+
_make(
|
|
147
|
+
tmp_path / 'b.avhdx', fake_vhdi,
|
|
148
|
+
parent_filename='a.avhdx',
|
|
149
|
+
media_size=4096, identifier='b-id', parent_identifier='a-id',
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
with pytest.raises(
|
|
153
|
+
NtfsDumpError, match="Circular VHDX parent reference detected"
|
|
154
|
+
):
|
|
155
|
+
open_vhdx_chain(tmp_path / 'a.avhdx')
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_windows_absolute_parent(tmp_path: Path, fake_vhdi):
|
|
159
|
+
_make(
|
|
160
|
+
tmp_path / 'base.vhdx', fake_vhdi,
|
|
161
|
+
media_size=8192, identifier='base-id',
|
|
162
|
+
)
|
|
163
|
+
child = _make(
|
|
164
|
+
tmp_path / 'vm.avhdx', fake_vhdi,
|
|
165
|
+
parent_filename='C:\\VMs\\Base\\base.vhdx',
|
|
166
|
+
media_size=8192, identifier='child-id', parent_identifier='base-id',
|
|
167
|
+
)
|
|
168
|
+
chain = open_vhdx_chain(child)
|
|
169
|
+
|
|
170
|
+
assert len(chain.handles) == 2
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_relative_parent_path_with_parent_directory(tmp_path: Path, fake_vhdi):
|
|
174
|
+
(tmp_path / 'base').mkdir()
|
|
175
|
+
_make(
|
|
176
|
+
tmp_path / 'base' / 'base.vhdx', fake_vhdi,
|
|
177
|
+
media_size=4096, identifier='base-id',
|
|
178
|
+
)
|
|
179
|
+
(tmp_path / 'child').mkdir()
|
|
180
|
+
child = _make(
|
|
181
|
+
tmp_path / 'child' / 'vm.avhdx', fake_vhdi,
|
|
182
|
+
parent_filename='../base/base.vhdx',
|
|
183
|
+
media_size=4096, identifier='child-id', parent_identifier='base-id',
|
|
184
|
+
)
|
|
185
|
+
chain = open_vhdx_chain(child)
|
|
186
|
+
|
|
187
|
+
assert len(chain.handles) == 2
|
|
188
|
+
assert chain.handle._parent is chain.handles[1]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_parent_identifier_mismatch_raises(tmp_path: Path, fake_vhdi):
|
|
192
|
+
_make(
|
|
193
|
+
tmp_path / 'base.vhdx', fake_vhdi,
|
|
194
|
+
media_size=4096, identifier='base-id',
|
|
195
|
+
)
|
|
196
|
+
child = _make(
|
|
197
|
+
tmp_path / 'vm.avhdx', fake_vhdi,
|
|
198
|
+
parent_filename='base.vhdx',
|
|
199
|
+
media_size=4096, identifier='child-id', parent_identifier='wrong-id',
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
with pytest.raises(
|
|
203
|
+
NtfsDumpError, match="Unable to attach parent AVHDX: base.vhdx"
|
|
204
|
+
):
|
|
205
|
+
open_vhdx_chain(child)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_non_vhdx_missing_parent_message(tmp_path: Path, fake_vhdi):
|
|
209
|
+
child = _make(
|
|
210
|
+
tmp_path / 'legacy.vhd', fake_vhdi,
|
|
211
|
+
parent_filename='missing.vhd',
|
|
212
|
+
media_size=4096, identifier='child-id', parent_identifier='base-id',
|
|
213
|
+
vhdx=False,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
with pytest.raises(
|
|
217
|
+
NtfsDumpError, match="Parent VHD was not found: missing.vhd"
|
|
218
|
+
):
|
|
219
|
+
open_vhdx_chain(child)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_vhd_handler_reads_logical_disk(tmp_path: Path, fake_vhdi):
|
|
223
|
+
base = _make(tmp_path / 'base.vhdx', fake_vhdi, media_size=4096)
|
|
224
|
+
|
|
225
|
+
img_info = VHDHandler().get_img_info(str(base))
|
|
226
|
+
|
|
227
|
+
assert img_info.get_size() == 4096
|
|
228
|
+
assert img_info.read(0, 4) == b'\x00\x00\x00\x00'
|
|
229
|
+
img_info.close()
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# coding: utf-8
|
|
2
|
+
"""End-to-end VHDX/AVHDX tests using real pyvhdi handles (no mocks).
|
|
3
|
+
|
|
4
|
+
Minimal dynamic + differential VHDX images are crafted byte-by-byte (VHDX
|
|
5
|
+
structures: file type identifier, image headers, region tables with CRC-32C,
|
|
6
|
+
metadata region with parent locator, BAT) so that the Hyper-V checkpoint
|
|
7
|
+
parent fallback is exercised through the real libvhdi implementation.
|
|
8
|
+
"""
|
|
9
|
+
import struct
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SECTOR = 512
|
|
16
|
+
BLOCK_SIZE = 1024 * 1024
|
|
17
|
+
DISK_SIZE = 4 * 1024 * 1024
|
|
18
|
+
NUM_BLOCKS = DISK_SIZE // BLOCK_SIZE
|
|
19
|
+
PAYLOAD_OFFSET = 3 * 1024 * 1024
|
|
20
|
+
METADATA_OFFSET = 1024 * 1024
|
|
21
|
+
BAT_OFFSET = 2 * 1024 * 1024
|
|
22
|
+
|
|
23
|
+
GUID_BAT = bytes([0x66, 0x77, 0xC2, 0x2D, 0x23, 0xF6, 0x00, 0x42,
|
|
24
|
+
0x9D, 0x64, 0x11, 0x5E, 0x9B, 0xFD, 0x4A, 0x08])
|
|
25
|
+
GUID_METADATA = bytes([0x06, 0xA2, 0x7C, 0x8B, 0x90, 0x47, 0x9A, 0x4B,
|
|
26
|
+
0xB8, 0xFE, 0x57, 0x5F, 0x05, 0x0F, 0x88, 0x6E])
|
|
27
|
+
GUID_FILE_PARAMS = bytes([0x37, 0x67, 0xA1, 0xCA, 0x36, 0xFA, 0x43, 0x4D,
|
|
28
|
+
0xB3, 0xB6, 0x33, 0xF0, 0xAA, 0x44, 0xE7, 0x6B])
|
|
29
|
+
GUID_LOGICAL_SECTOR = bytes([0x1D, 0xBF, 0x41, 0x81, 0x6F, 0xA9, 0x09, 0x47,
|
|
30
|
+
0xBA, 0x47, 0xF2, 0x33, 0xA8, 0xFA, 0xAB, 0x5F])
|
|
31
|
+
GUID_PHYSICAL_SECTOR = bytes([0xC7, 0x48, 0xA3, 0xCD, 0x5D, 0x44, 0x71, 0x44,
|
|
32
|
+
0x9C, 0xC9, 0xE9, 0x88, 0x52, 0x51, 0xC5, 0x56])
|
|
33
|
+
GUID_DISK_ID = bytes([0xAB, 0x12, 0xCA, 0xBE, 0xE6, 0xB2, 0x23, 0x45,
|
|
34
|
+
0x93, 0xEF, 0xC3, 0x09, 0xE0, 0x00, 0xC7, 0x46])
|
|
35
|
+
GUID_DISK_SIZE = bytes([0x24, 0x42, 0xA5, 0x2F, 0x1B, 0xCD, 0x76, 0x48,
|
|
36
|
+
0xB2, 0x11, 0x5D, 0xBE, 0xD8, 0x3B, 0xF4, 0xB8])
|
|
37
|
+
GUID_PARENT_LOCATOR_ITEM = bytes([0x2D, 0x5F, 0xD3, 0xA8, 0x0B, 0xB3, 0x4D, 0x45,
|
|
38
|
+
0xAB, 0xF7, 0xD3, 0xD8, 0x48, 0x34, 0xAB, 0x0C])
|
|
39
|
+
# Parent locator type indicator (b04aefb7-d19e-4fea-b789-25b8e9445913).
|
|
40
|
+
GUID_PARENT_LOCATOR_TYPE = bytes([0xB7, 0xEF, 0x4A, 0xB0, 0x9E, 0xD1, 0x81, 0x4A,
|
|
41
|
+
0xB7, 0x89, 0x25, 0xB8, 0xE9, 0x44, 0x59, 0x13])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def crc32c(data: bytes) -> int:
|
|
45
|
+
# libvhdi uses CRC-32C (Castagnoli polynomial 0x82f63b78).
|
|
46
|
+
table = []
|
|
47
|
+
for index in range(256):
|
|
48
|
+
checksum = index
|
|
49
|
+
for _ in range(8):
|
|
50
|
+
checksum = (0x82F63B78 ^ (checksum >> 1)) if (checksum & 1) else (checksum >> 1)
|
|
51
|
+
table.append(checksum)
|
|
52
|
+
checksum = 0xFFFFFFFF
|
|
53
|
+
for byte in data:
|
|
54
|
+
checksum = table[(checksum ^ byte) & 0xFF] ^ (checksum >> 8)
|
|
55
|
+
return checksum ^ 0xFFFFFFFF
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def crc32(data: bytes) -> int:
|
|
59
|
+
return crc32c(data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def file_type_identifier() -> bytes:
|
|
63
|
+
return b'vhdxfile' + b'\x00' * 56
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def image_header(file_size: int, sequence: int,
|
|
67
|
+
data_write_uuid: 'uuid.UUID | None' = None) -> bytes:
|
|
68
|
+
h = bytearray(4096)
|
|
69
|
+
h[0:4] = b'head'
|
|
70
|
+
struct.pack_into('<Q', h, 8, sequence)
|
|
71
|
+
h[16:32] = uuid.uuid4().bytes
|
|
72
|
+
# VHDX: set_parent compares the child's parent_linkage GUID against the
|
|
73
|
+
# parent's data write identifier.
|
|
74
|
+
h[32:48] = (data_write_uuid.bytes_le if data_write_uuid
|
|
75
|
+
else uuid.uuid4().bytes)
|
|
76
|
+
h[48:64] = uuid.uuid4().bytes
|
|
77
|
+
struct.pack_into('<H', h, 64, 0) # log format version
|
|
78
|
+
struct.pack_into('<H', h, 66, 1) # format version
|
|
79
|
+
struct.pack_into('<I', h, 68, 0) # log size
|
|
80
|
+
struct.pack_into('<Q', h, 72, 0) # log offset
|
|
81
|
+
struct.pack_into('<I', h, 4, crc32(bytes(h)))
|
|
82
|
+
return bytes(h)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def region_table(metadata_offset: int, bat_offset: int) -> bytes:
|
|
86
|
+
# libvhdi validates the CRC32 over the whole 64KB region table area
|
|
87
|
+
# (header with checksum zeroed + padding).
|
|
88
|
+
t = bytearray(64 * 1024)
|
|
89
|
+
t[0:4] = b'regi'
|
|
90
|
+
struct.pack_into('<I', t, 8, 2) # number of entries
|
|
91
|
+
struct.pack_into('<16sQII', t, 16, GUID_METADATA,
|
|
92
|
+
metadata_offset, 1024 * 1024, 1)
|
|
93
|
+
struct.pack_into('<16sQII', t, 48, GUID_BAT,
|
|
94
|
+
bat_offset, 1024 * 1024, 1)
|
|
95
|
+
struct.pack_into('<I', t, 4, crc32(bytes(t)))
|
|
96
|
+
return bytes(t)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def parent_locator_item(parent_name: str, parent_uuid: uuid.UUID) -> bytes:
|
|
100
|
+
key0 = 'absolute_win32_path\x00'.encode('utf-16-le')
|
|
101
|
+
key1 = 'parent_linkage\x00'.encode('utf-16-le')
|
|
102
|
+
val0 = parent_name.encode('utf-16-le') + b'\x00\x00'
|
|
103
|
+
val1 = ('{' + str(parent_uuid) + '}\x00').encode('utf-16-le')
|
|
104
|
+
|
|
105
|
+
item = bytearray()
|
|
106
|
+
item += GUID_PARENT_LOCATOR_TYPE
|
|
107
|
+
item += struct.pack('<HH', 0, 2) # reserved, entry count
|
|
108
|
+
base = 20 + 2 * 12 # 2 entries x 12 bytes
|
|
109
|
+
item += struct.pack('<IIHH', base, base + len(key0), len(key0), len(val0))
|
|
110
|
+
item += struct.pack('<IIHH', base + len(key0) + len(val0),
|
|
111
|
+
base + len(key0) + len(val0) + len(key1),
|
|
112
|
+
len(key1), len(val1))
|
|
113
|
+
item += key0 + val0 + key1 + val1
|
|
114
|
+
return bytes(item)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def metadata_region(block_size: int, disk_size: int, disk_uuid: uuid.UUID,
|
|
118
|
+
differential: bool, parent_item: bytes = b'') -> bytes:
|
|
119
|
+
items = [
|
|
120
|
+
(GUID_FILE_PARAMS,
|
|
121
|
+
struct.pack('<II', block_size, 2 if differential else 0)),
|
|
122
|
+
(GUID_DISK_SIZE, struct.pack('<Q', disk_size)),
|
|
123
|
+
(GUID_LOGICAL_SECTOR, struct.pack('<I', SECTOR)),
|
|
124
|
+
(GUID_PHYSICAL_SECTOR, struct.pack('<I', SECTOR)),
|
|
125
|
+
(GUID_DISK_ID, disk_uuid.bytes_le),
|
|
126
|
+
]
|
|
127
|
+
if parent_item:
|
|
128
|
+
items.append((GUID_PARENT_LOCATOR_ITEM, parent_item))
|
|
129
|
+
|
|
130
|
+
table_size = 32 + 32 * len(items)
|
|
131
|
+
blob = bytearray()
|
|
132
|
+
offsets = []
|
|
133
|
+
# VHDX spec/libvhdi require item offsets to be >= 64KB within the region.
|
|
134
|
+
pos = max(64 * 1024, (table_size + 7) & ~7)
|
|
135
|
+
for guid, data in items:
|
|
136
|
+
offsets.append(pos)
|
|
137
|
+
pos = (pos + len(data) + 7) & ~7
|
|
138
|
+
|
|
139
|
+
region = bytearray(1024 * 1024)
|
|
140
|
+
region[0:8] = b'metadata' # metadata table header
|
|
141
|
+
struct.pack_into('<H', region, 8, 0) # reserved
|
|
142
|
+
struct.pack_into('<H', region, 10, len(items))
|
|
143
|
+
entry_offset = 32
|
|
144
|
+
for index, ((guid, data), offset) in enumerate(zip(items, offsets)):
|
|
145
|
+
struct.pack_into('<16sIII', region, entry_offset + 32 * index,
|
|
146
|
+
guid, offset, len(data), 0)
|
|
147
|
+
start = offsets[0]
|
|
148
|
+
for (guid, data), offset in zip(items, offsets):
|
|
149
|
+
rel = offset - start
|
|
150
|
+
if len(blob) < rel:
|
|
151
|
+
blob += b'\x00' * (rel - len(blob))
|
|
152
|
+
blob += data
|
|
153
|
+
region[start:start + len(blob)] = blob
|
|
154
|
+
return bytes(region)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def bat_region(entries):
|
|
158
|
+
bat = bytearray(1024 * 1024)
|
|
159
|
+
for index, entry in enumerate(entries):
|
|
160
|
+
struct.pack_into('<Q', bat, 8 * index, entry)
|
|
161
|
+
return bytes(bat)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def make_vhdx(path: Path, disk_uuid: uuid.UUID, differential: bool,
|
|
165
|
+
parent_name: str = '', parent_uuid: uuid.UUID = None,
|
|
166
|
+
marker: bool = False,
|
|
167
|
+
data_write_uuid: 'uuid.UUID | None' = None) -> None:
|
|
168
|
+
f = bytearray()
|
|
169
|
+
f += file_type_identifier()
|
|
170
|
+
f += b'\x00' * (0x10000 - len(f))
|
|
171
|
+
f += image_header(0, 1, data_write_uuid) # placeholder file size
|
|
172
|
+
f += b'\x00' * (0x20000 - len(f))
|
|
173
|
+
f += image_header(0, 2, data_write_uuid)
|
|
174
|
+
f += b'\x00' * (0x30000 - len(f))
|
|
175
|
+
f += region_table(METADATA_OFFSET, BAT_OFFSET)
|
|
176
|
+
f += b'\x00' * (0x40000 - len(f))
|
|
177
|
+
f += region_table(METADATA_OFFSET, BAT_OFFSET)
|
|
178
|
+
f += b'\x00' * (METADATA_OFFSET - len(f))
|
|
179
|
+
|
|
180
|
+
if differential:
|
|
181
|
+
locator = parent_locator_item(parent_name, parent_uuid)
|
|
182
|
+
f += metadata_region(BLOCK_SIZE, DISK_SIZE, disk_uuid, True, locator)
|
|
183
|
+
f += bat_region([0] * NUM_BLOCKS)
|
|
184
|
+
else:
|
|
185
|
+
f += metadata_region(BLOCK_SIZE, DISK_SIZE, disk_uuid, False)
|
|
186
|
+
entries = [(PAYLOAD_OFFSET // (1024 * 1024) + i) << 20 | 6
|
|
187
|
+
for i in range(NUM_BLOCKS)]
|
|
188
|
+
f += bat_region(entries)
|
|
189
|
+
f += b'\x00' * (PAYLOAD_OFFSET - len(f))
|
|
190
|
+
for i in range(NUM_BLOCKS):
|
|
191
|
+
block = bytearray(BLOCK_SIZE)
|
|
192
|
+
if marker and i == 0:
|
|
193
|
+
block[128 * 1024:128 * 1024 + 10] = b'PARENTDATA'
|
|
194
|
+
f += bytes(block)
|
|
195
|
+
|
|
196
|
+
path.write_bytes(bytes(f))
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_real_vhdx_differential_parent_fallback(tmp_path: Path):
|
|
200
|
+
from ntfsdump.formats.vhd import VHDHandler
|
|
201
|
+
|
|
202
|
+
base_uuid = uuid.uuid4()
|
|
203
|
+
make_vhdx(
|
|
204
|
+
tmp_path / 'base.vhdx', base_uuid, False, marker=True,
|
|
205
|
+
data_write_uuid=base_uuid,
|
|
206
|
+
)
|
|
207
|
+
make_vhdx(
|
|
208
|
+
tmp_path / 'diff.avhdx', uuid.uuid4(), True,
|
|
209
|
+
parent_name='base.vhdx', parent_uuid=base_uuid,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
img_info = VHDHandler().get_img_info(str(tmp_path / 'diff.avhdx'))
|
|
213
|
+
try:
|
|
214
|
+
assert img_info.get_size() == 4 * 1024 * 1024
|
|
215
|
+
assert img_info.read(128 * 1024, 10) == b'PARENTDATA'
|
|
216
|
+
finally:
|
|
217
|
+
img_info.close()
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# coding: utf-8
|
|
2
|
-
import pyvhdi
|
|
3
|
-
import pytsk3
|
|
4
|
-
from ntfsdump.formats.base import FormatHandler, BaseImgInfo
|
|
5
|
-
|
|
6
|
-
class VHDHandler(FormatHandler):
|
|
7
|
-
def get_img_info(self, path: str) -> pytsk3.Img_Info:
|
|
8
|
-
vhdi_file = pyvhdi.file()
|
|
9
|
-
vhdi_file.open(path, mode='r')
|
|
10
|
-
return BaseImgInfo(vhdi_file)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|