ntfsdump 3.2.0__tar.gz → 3.3.1__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.
Files changed (52) hide show
  1. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/PKG-INFO +18 -2
  2. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/README.md +17 -1
  3. ntfsdump-3.3.1/src/ntfsdump/__about__.py +2 -0
  4. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/cli.py +4 -1
  5. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/core.py +5 -0
  6. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/formats/detect.py +20 -4
  7. ntfsdump-3.3.1/src/ntfsdump/formats/vhd.py +110 -0
  8. ntfsdump-3.3.1/src/ntfsdump/sources/__init__.py +24 -0
  9. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/sources/resolver.py +47 -1
  10. ntfsdump-3.3.1/tests/ntfsdump/test_core.py +45 -0
  11. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/test_detect.py +24 -1
  12. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/test_source.py +39 -1
  13. ntfsdump-3.3.1/tests/ntfsdump/test_vhd_real.py +98 -0
  14. ntfsdump-3.3.1/tests/ntfsdump/test_vhdx_chain.py +229 -0
  15. ntfsdump-3.3.1/tests/ntfsdump/test_vhdx_real.py +217 -0
  16. ntfsdump-3.2.0/src/ntfsdump/__about__.py +0 -2
  17. ntfsdump-3.2.0/src/ntfsdump/formats/vhd.py +0 -10
  18. ntfsdump-3.2.0/src/ntfsdump/sources/__init__.py +0 -9
  19. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/.devcontainer/Dockerfile +0 -0
  20. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/.devcontainer/devcontainer.json +0 -0
  21. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/.devcontainer/postCreateCommands.fish +0 -0
  22. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/.github/workflows/publish-binary-pypi.yaml +0 -0
  23. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/.github/workflows/publish-release.yaml +0 -0
  24. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/.github/workflows/test.yaml +0 -0
  25. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/.gitignore +0 -0
  26. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/Dockerfile +0 -0
  27. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/LICENSE +0 -0
  28. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/docker-compose.yml +0 -0
  29. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/pyproject.toml +0 -0
  30. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/__init__.py +0 -0
  31. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/__main__.py +0 -0
  32. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/errors.py +0 -0
  33. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/formats/__init__.py +0 -0
  34. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/formats/base.py +0 -0
  35. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/formats/e01.py +0 -0
  36. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/formats/raw.py +0 -0
  37. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/formats/vmdk.py +0 -0
  38. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/image.py +0 -0
  39. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/logger.py +0 -0
  40. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/vmware/__init__.py +0 -0
  41. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/vmware/metadata.py +0 -0
  42. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/src/ntfsdump/volume.py +0 -0
  43. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/cache/.gitkeep +0 -0
  44. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/conftest.py +0 -0
  45. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/fixtures/vmware/SingleVM/SingleVM.vmx +0 -0
  46. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/fixtures/vmware/WindowsVM/WindowsVM.vmsd +0 -0
  47. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/fixtures/vmware/WindowsVM/WindowsVM.vmx +0 -0
  48. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/test__init__.py +0 -0
  49. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/test_cli.py +0 -0
  50. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/test_vmdk_chain.py +0 -0
  51. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/tests/ntfsdump/test_vmware.py +0 -0
  52. {ntfsdump-3.2.0 → ntfsdump-3.3.1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ntfsdump
3
- Version: 3.2.0
3
+ Version: 3.3.1
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`, `VMDK` (auto-detected), plus VMware VM directories, VMX and VMSD
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`, `VMDK` (auto-detected), plus VMware VM directories, VMX and VMSD
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
 
@@ -0,0 +1,2 @@
1
+ # coding: utf-8
2
+ __version__ = "3.3.1"
@@ -36,6 +36,9 @@ examples:
36
36
 
37
37
  ntfsdump image.raw /$MFT --log ./case.log
38
38
  enable logging to an explicit file (logging is disabled by default).
39
+
40
+ ntfsdump ./HyperVM/Disk_0.avhdx /$MFT
41
+ read a Hyper-V checkpoint chain (VHDX/AVHDX) without merging it.
39
42
  """
40
43
 
41
44
 
@@ -238,6 +241,7 @@ def _run(args) -> None:
238
241
  snapshot=args.snapshot,
239
242
  disk=args.disk,
240
243
  flat=args.flat,
244
+ quiet=args.quiet,
241
245
  )
242
246
 
243
247
 
@@ -245,7 +249,6 @@ def main(argv: Optional[Iterable[str]] = None) -> int:
245
249
  parser = create_parser()
246
250
  args = parser.parse_args(argv)
247
251
 
248
- MetaData.quiet = args.quiet
249
252
  configure_logging(args.log)
250
253
 
251
254
  try:
@@ -3,6 +3,7 @@ from pathlib import Path
3
3
  from typing import Optional, Union
4
4
 
5
5
  from ntfsdump.image import ImageFile
6
+ from ntfsdump.logger import MetaData
6
7
 
7
8
 
8
9
  def ntfsdump(
@@ -14,6 +15,7 @@ def ntfsdump(
14
15
  snapshot=None,
15
16
  disk: Optional[int] = None,
16
17
  flat: bool = False,
18
+ quiet: bool = False,
17
19
  ) -> None:
18
20
  """A tool for extracting files from an NTFS volume on a disk image.
19
21
 
@@ -27,7 +29,10 @@ def ntfsdump(
27
29
  snapshot (optional): VMware snapshot ID to read. Defaults to None.
28
30
  disk (Optional[int], optional): VMware virtual disk ID to read. Defaults to None.
29
31
  flat (bool, optional): reconstruct directory tree or not. Defaults to False.
32
+ quiet (bool, optional): suppress progress lines on stdout.
33
+ Errors are still written to stderr. Defaults to False.
30
34
  """
35
+ MetaData.quiet = quiet
31
36
  img = ImageFile(
32
37
  source,
33
38
  volume=volume,
@@ -8,10 +8,20 @@ import pyvmdk
8
8
  from ntfsdump.errors import NtfsDumpError
9
9
 
10
10
 
11
- def _is_vhdx(path: Path) -> bool:
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) == b'vhdxfile'
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 _is_vhdx(p) else 'vhd'
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
- return FileSource(path, resolved_format)
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:
@@ -0,0 +1,45 @@
1
+ # coding: utf-8
2
+ import pytest
3
+
4
+ from ntfsdump import core
5
+ from ntfsdump.logger import MetaData
6
+
7
+
8
+ def test_ntfsdump_resets_leaked_quiet_state(tmp_path, monkeypatch):
9
+ # A previous ntfsfind(verbose=True) call may leave MetaData.quiet=True
10
+ # in the same process; core.ntfsdump() must reset it explicitly.
11
+ monkeypatch.setattr(MetaData, 'quiet', True)
12
+
13
+ with pytest.raises(Exception):
14
+ core.ntfsdump(source=str(tmp_path), paths=['/$MFT'])
15
+
16
+ assert MetaData.quiet is False
17
+
18
+
19
+ def test_ntfsdump_quiet_flag_is_applied(tmp_path, monkeypatch):
20
+ monkeypatch.setattr(MetaData, 'quiet', False)
21
+
22
+ with pytest.raises(Exception):
23
+ core.ntfsdump(source=str(tmp_path), paths=['/$MFT'], quiet=True)
24
+
25
+ assert MetaData.quiet is True
26
+
27
+
28
+ def test_cli_passes_quiet_to_core(monkeypatch):
29
+ from ntfsdump import cli
30
+
31
+ captured = {}
32
+
33
+ def fake_ntfsdump(**kwargs):
34
+ captured.update(kwargs)
35
+
36
+ monkeypatch.setattr(cli, 'ntfsdump', fake_ntfsdump)
37
+ monkeypatch.setattr(
38
+ cli.SourceResolver,
39
+ 'resolve',
40
+ lambda self, source, **kwargs: object(),
41
+ )
42
+
43
+ cli.main([str('/nonexistent'), '/$MFT', '--quiet'])
44
+
45
+ assert captured['quiet'] is True
@@ -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 FileSource, SourceResolver, VmwareSource
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,2 +0,0 @@
1
- # coding: utf-8
2
- __version__ = "3.2.0"
@@ -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)
@@ -1,9 +0,0 @@
1
- # coding: utf-8
2
- from ntfsdump.sources.resolver import (
3
- FileSource,
4
- Source,
5
- SourceResolver,
6
- VmwareSource,
7
- )
8
-
9
- __all__ = ["FileSource", "Source", "SourceResolver", "VmwareSource"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes