ntfsdump 3.3.2__tar.gz → 3.3.5__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 (75) hide show
  1. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/.devcontainer/Dockerfile +8 -5
  2. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/.devcontainer/devcontainer.json +8 -5
  3. ntfsdump-3.3.5/.devcontainer/postCreateCommands.fish +3 -0
  4. ntfsdump-3.3.5/.dockerignore +16 -0
  5. ntfsdump-3.3.5/.flake8 +3 -0
  6. ntfsdump-3.3.5/.gitattributes +2 -0
  7. ntfsdump-3.3.5/.github/scripts/collect_licenses.py +426 -0
  8. ntfsdump-3.3.5/.github/scripts/vendor/sleuthkit-4.15.0/licenses/IBM-LICENSE +221 -0
  9. ntfsdump-3.3.5/.github/scripts/vendor/sleuthkit-4.15.0/licenses/bsd.txt +16 -0
  10. ntfsdump-3.3.5/.github/scripts/vendor/sleuthkit-4.15.0/licenses/cpl1.0.txt +213 -0
  11. ntfsdump-3.3.5/.github/scripts/vendor/sleuthkit-4.15.0/licenses/mit.txt +24 -0
  12. ntfsdump-3.3.5/.github/workflows/publish-release.yaml +131 -0
  13. ntfsdump-3.3.5/.github/workflows/test.yaml +65 -0
  14. ntfsdump-3.3.5/Dockerfile +22 -0
  15. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/PKG-INFO +36 -4
  16. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/README.md +34 -2
  17. ntfsdump-3.3.5/docker-compose.yml +8 -0
  18. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/pyproject.toml +15 -7
  19. ntfsdump-3.3.5/src/ntfsdump/__about__.py +2 -0
  20. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/__init__.py +1 -1
  21. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/cli.py +100 -58
  22. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/core.py +13 -4
  23. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/__init__.py +7 -8
  24. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/base.py +16 -2
  25. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/detect.py +11 -12
  26. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/e01.py +3 -2
  27. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/raw.py +1 -0
  28. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/vhd.py +19 -16
  29. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/vmdk.py +29 -13
  30. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/image.py +74 -28
  31. ntfsdump-3.3.5/src/ntfsdump/image_paths.py +45 -0
  32. ntfsdump-3.3.5/src/ntfsdump/logger.py +173 -0
  33. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/sources/resolver.py +17 -19
  34. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/vmware/metadata.py +53 -45
  35. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/volume.py +94 -49
  36. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_core.py +43 -0
  37. ntfsdump-3.3.5/tests/ntfsdump/test_directory_ads_real.py +393 -0
  38. ntfsdump-3.3.5/tests/ntfsdump/test_e01.py +31 -0
  39. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_image_os_volume.py +1 -1
  40. ntfsdump-3.3.5/tests/ntfsdump/test_logger.py +127 -0
  41. ntfsdump-3.3.5/tests/ntfsdump/test_partition_image.py +365 -0
  42. ntfsdump-3.3.5/tests/ntfsdump/test_path_fidelity.py +89 -0
  43. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vhd_real.py +10 -0
  44. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vhdx_real.py +51 -5
  45. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vmdk_chain.py +40 -0
  46. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vmware.py +15 -0
  47. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_volume.py +278 -180
  48. ntfsdump-3.3.5/tests/test_license_collection.py +371 -0
  49. ntfsdump-3.3.5/uv.lock +645 -0
  50. ntfsdump-3.3.2/.devcontainer/postCreateCommands.fish +0 -2
  51. ntfsdump-3.3.2/.github/workflows/publish-binary-pypi.yaml +0 -25
  52. ntfsdump-3.3.2/.github/workflows/publish-release.yaml +0 -145
  53. ntfsdump-3.3.2/.github/workflows/test.yaml +0 -52
  54. ntfsdump-3.3.2/Dockerfile +0 -14
  55. ntfsdump-3.3.2/docker-compose.yml +0 -18
  56. ntfsdump-3.3.2/src/ntfsdump/__about__.py +0 -2
  57. ntfsdump-3.3.2/src/ntfsdump/image_paths.py +0 -22
  58. ntfsdump-3.3.2/src/ntfsdump/logger.py +0 -105
  59. ntfsdump-3.3.2/uv.lock +0 -527
  60. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/.gitignore +0 -0
  61. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/LICENSE +0 -0
  62. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/__main__.py +0 -0
  63. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/errors.py +0 -0
  64. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/sources/__init__.py +0 -0
  65. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/vmware/__init__.py +0 -0
  66. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/cache/.gitkeep +0 -0
  67. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/conftest.py +0 -0
  68. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/fixtures/vmware/SingleVM/SingleVM.vmx +0 -0
  69. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/fixtures/vmware/WindowsVM/WindowsVM.vmsd +0 -0
  70. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/fixtures/vmware/WindowsVM/WindowsVM.vmx +0 -0
  71. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test__init__.py +0 -0
  72. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_cli.py +0 -0
  73. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_detect.py +0 -0
  74. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_source.py +0 -0
  75. {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vhdx_chain.py +0 -0
@@ -1,12 +1,13 @@
1
- FROM python:3.13.12-slim-trixie
1
+ # syntax=docker/dockerfile:1
2
+ FROM python:3.13.16-slim-trixie
2
3
 
3
- RUN apt -y update && apt upgrade -qqy && apt -y install \
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
4
5
  curl \
5
- gcc \
6
+ build-essential \
6
7
  git \
7
8
  fish \
8
9
  wget \
9
- && apt clean
10
+ && rm -rf /var/lib/apt/lists/*
10
11
 
11
12
  ARG USERNAME=dev
12
13
  ARG USER_UID=1000
@@ -22,4 +23,6 @@ RUN groupadd --gid $USER_GID $USERNAME \
22
23
  RUN chsh -s /usr/bin/fish $USERNAME
23
24
 
24
25
  USER $USERNAME
25
- RUN pip install -U pip && pip install uv
26
+ RUN --mount=type=secret,id=proxy_ca,mode=0444 \
27
+ if [ -f /run/secrets/proxy_ca ]; then export PIP_CERT=/run/secrets/proxy_ca; fi; \
28
+ python -m pip install --user uv==0.12.23
@@ -7,18 +7,21 @@
7
7
  "extensions": [
8
8
  "ms-azuretools.vscode-docker",
9
9
  "ms-python.python",
10
- "ms-python.vscode-pylance"
10
+ "ms-python.vscode-pylance",
11
+ "ms-python.black-formatter",
12
+ "ms-python.flake8",
13
+ "ms-python.isort"
11
14
  ],
12
15
  "settings": {
13
- "python.linting.flake8Enabled": true,
14
- "python.formatting.provider": "black",
16
+ "python.defaultInterpreterPath": "${containerWorkspaceFolder}/.venv/bin/python",
15
17
  "[python]": {
18
+ "editor.defaultFormatter": "ms-python.black-formatter",
16
19
  "editor.formatOnSave": true,
17
20
  "editor.codeActionsOnSave": {
18
- "source.organizeImports": true
21
+ "source.organizeImports": "explicit"
19
22
  }
20
23
  }
21
24
  }
22
25
  }
23
26
  }
24
- }
27
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/fish
2
+ fish_add_path --universal $HOME/.local/bin
3
+ uv sync --locked
@@ -0,0 +1,16 @@
1
+ .git
2
+ .venv
3
+ __pycache__
4
+ .pytest_cache
5
+ .mypy_cache
6
+ .cache
7
+ tests
8
+ build
9
+ dist
10
+ *.raw
11
+ *.tgs
12
+ *.E01
13
+ *.vhd
14
+ *.vhdx
15
+ *.vmdk
16
+ .env
ntfsdump-3.3.5/.flake8 ADDED
@@ -0,0 +1,3 @@
1
+ [flake8]
2
+ max-line-length = 88
3
+ extend-ignore = E203, W503
@@ -0,0 +1,2 @@
1
+ # License collector verifies these vendored notices against byte-exact SHA-256 hashes.
2
+ /.github/scripts/vendor/** -text
@@ -0,0 +1,426 @@
1
+ """Collect notices from installed distributions or verified matching source archives.
2
+
3
+ Run with the same locked environment used to compile the standalone executable.
4
+ No mutable upstream branch is used for license retrieval.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import io
10
+ import json
11
+ from importlib.metadata import distribution
12
+ from pathlib import Path
13
+ import re
14
+ import sys
15
+ import sysconfig
16
+ import tarfile
17
+ import tomllib
18
+ from urllib.request import urlopen
19
+
20
+
21
+ MIT_PERMISSION = """Permission is hereby granted, free of charge, to any person obtaining a copy
22
+ of this software and associated documentation files (the "Software"), to deal
23
+ in the Software without restriction, including without limitation the rights
24
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
25
+ copies of the Software, and to permit persons to whom the Software is
26
+ furnished to do so, subject to the following conditions:
27
+
28
+ The above copyright notice and this permission notice shall be included in
29
+ all copies or substantial portions of the Software.
30
+
31
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
32
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
33
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
34
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
35
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
36
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
37
+ THE SOFTWARE.
38
+ """
39
+
40
+ RUST_NOTICE_EXCEPTIONS = {
41
+ # This exact registry archive omits its declared MIT/Apache notice files.
42
+ # Its MIT option is unsupported by the archive; the already-collected
43
+ # mft 0.9.1 crate carries the original Apache-2.0 notice text.
44
+ ("pyo3-file", "0.18.0", "a39bef38c86a2084dcad332d4fc5aeb53c0022c21ce1853e2ee27e5d8de6b19b"):
45
+ {
46
+ "license": "MIT/Apache-2.0",
47
+ "license_text": "rust-crates/mft-0.9.1/LICENSE-APACHE",
48
+ "reason": "The checksum-pinned pyo3-file archive contains no original license or copyright files. Its Cargo.toml declares MIT/Apache-2.0, and the collected mft 0.9.1 archive supplies the Apache-2.0 text as the documented licensing option.",
49
+ },
50
+ ("utf16-simd", "0.1.1", "4be118d4436ac3c0d994602e970e2a06eb75eaa096a42e061d533af0ee678b8f"):
51
+ {
52
+ "license": "MIT OR Apache-2.0",
53
+ "license_text": "rust-crates/mft-0.9.1/LICENSE-APACHE",
54
+ "reason": "The checksum-pinned utf16-simd archive contains no original license or copyright files. Its Cargo.toml declares MIT OR Apache-2.0, and the collected mft 0.9.1 archive supplies the Apache-2.0 text as an allowed licensing option.",
55
+ },
56
+ }
57
+
58
+ def is_notice(path: str) -> bool:
59
+ name = Path(path).name.upper()
60
+ return name.startswith(("LICENSE", "LICENCE", "COPYING", "NOTICE", "COPYRIGHT", "UNLICENSE")) or name in {
61
+ "CPL1.0.TXT", "BSD.TXT", "MIT.TXT"
62
+ }
63
+
64
+
65
+ def safe_archive_path(name: str) -> Path:
66
+ """Return an archive path after rejecting absolute and parent traversal paths."""
67
+ path = Path(*Path(name).parts)
68
+ if path.is_absolute() or ".." in path.parts:
69
+ raise ValueError(f"Unsafe source member: {name}")
70
+ return path
71
+
72
+
73
+ def locked_registry_packages(lock: dict) -> list[dict]:
74
+ """Return the exact registry crates pinned by Cargo.lock."""
75
+ for package in lock.get("package", []):
76
+ source = package.get("source")
77
+ if source and not source.startswith("registry+"):
78
+ raise ValueError(
79
+ f"Unsupported locked Rust dependency source: {package['name']} "
80
+ f"{package['version']} ({source})"
81
+ )
82
+ packages = [
83
+ package for package in lock.get("package", [])
84
+ if package.get("source", "").startswith("registry+")
85
+ ]
86
+ for package in packages:
87
+ if not package.get("checksum"):
88
+ raise ValueError(
89
+ f"Locked Rust dependency has no checksum: {package['name']} {package['version']}"
90
+ )
91
+ return packages
92
+
93
+
94
+ def _crate_license_metadata(crate: tarfile.TarFile, root: str) -> dict:
95
+ """Read Cargo license metadata from a crate archive without trusting its paths."""
96
+ member = next(
97
+ (item for item in crate.getmembers()
98
+ if item.isfile() and item.name == f"{root}/Cargo.toml"),
99
+ None,
100
+ )
101
+ if member is None:
102
+ raise ValueError(f"Rust crate archive is missing {root}/Cargo.toml")
103
+ cargo = tomllib.loads(crate.extractfile(member).read().decode("utf-8"))
104
+ package = cargo.get("package", {})
105
+ return {
106
+ "license": package.get("license"),
107
+ "license_file": package.get("license-file"),
108
+ "authors": package.get("authors", []),
109
+ }
110
+
111
+
112
+ def _matching_rust_notice_exception(package: dict, checksum: str, metadata: dict) -> dict | None:
113
+ exception = RUST_NOTICE_EXCEPTIONS.get((package["name"], package["version"], checksum))
114
+ if exception and metadata.get("license") == exception["license"]:
115
+ return exception
116
+ return None
117
+
118
+
119
+ def collect_locked_rust_notices(lock_data: bytes, destination: Path) -> tuple[list[str], list[dict]]:
120
+ """Collect source notices for every checksum-pinned registry crate in a Cargo lock."""
121
+ lock = tomllib.loads(lock_data.decode("utf-8"))
122
+ packages = locked_registry_packages(lock)
123
+ all_notices = []
124
+ records = []
125
+ unsupported_missing_notices = []
126
+ for package in packages:
127
+ name, version = package["name"], package["version"]
128
+ url = f"https://static.crates.io/crates/{name}/{name}-{version}.crate"
129
+ with urlopen(url, timeout=120) as response:
130
+ crate_data = response.read()
131
+ checksum = hashlib.sha256(crate_data).hexdigest()
132
+ if checksum != package["checksum"]:
133
+ raise ValueError(f"Rust crate checksum mismatch: {name} {version}")
134
+ root = f"{name}-{version}"
135
+ crate_notices = []
136
+ with tarfile.open(fileobj=io.BytesIO(crate_data)) as crate:
137
+ metadata = _crate_license_metadata(crate, root)
138
+ members = crate.getmembers()
139
+ for member in members:
140
+ if not member.isfile() or not member.name.startswith(root + "/"):
141
+ continue
142
+ relative = safe_archive_path(member.name[len(root) + 1:])
143
+ is_declared_file = metadata.get("license_file") and relative.as_posix() == metadata["license_file"]
144
+ data = crate.extractfile(member).read()
145
+ is_license_catalog = relative.name.upper() == "AUTHORS" and b"LICENSE:" in data.upper()
146
+ if not is_notice(str(relative)) and not is_declared_file and not is_license_catalog:
147
+ continue
148
+ target_relative = Path("rust-crates") / root / relative
149
+ target = destination / target_relative
150
+ target.parent.mkdir(parents=True, exist_ok=True)
151
+ target.write_bytes(data)
152
+ crate_notices.append(target_relative.as_posix())
153
+ license_file = metadata.get("license_file")
154
+ if license_file and not any(
155
+ path.removeprefix(f"rust-crates/{root}/") == license_file
156
+ for path in crate_notices
157
+ ):
158
+ raise ValueError(
159
+ f"Rust crate {name} {version} declares license-file {license_file!r} "
160
+ "but the checksum-verified archive does not contain it"
161
+ )
162
+ exception = _matching_rust_notice_exception(package, checksum, metadata)
163
+ if not crate_notices and not exception:
164
+ unsupported_missing_notices.append(f"{name} {version} ({metadata.get('license')!r})")
165
+ all_notices.extend(crate_notices)
166
+ records.append({
167
+ "name": name,
168
+ "version": version,
169
+ "source": {"url": url, "sha256": checksum},
170
+ "license": metadata.get("license"),
171
+ "license_file": metadata.get("license_file"),
172
+ "notices": crate_notices,
173
+ })
174
+ if exception:
175
+ records[-1]["notice_exception"] = exception["reason"]
176
+ records[-1]["notice_exception_license_text"] = exception["license_text"]
177
+ for record in records:
178
+ if "notice_exception_license_text" not in record:
179
+ continue
180
+ license_text = destination / record["notice_exception_license_text"]
181
+ if not license_text.is_file() or not license_text.stat().st_size:
182
+ raise ValueError(
183
+ f"Rust notice exception for {record['name']} {record['version']} "
184
+ f"requires missing license text {record['notice_exception_license_text']}"
185
+ )
186
+ if unsupported_missing_notices:
187
+ raise ValueError(
188
+ "Rust crates contain no supported license or copyright notice resources: "
189
+ + ", ".join(unsupported_missing_notices)
190
+ )
191
+ return all_notices, records
192
+
193
+
194
+ REQUIRED_NOTICES = {
195
+ name: {"COPYING", "COPYING.LESSER"}
196
+ for name in ("libewf-python", "libvhdi-python", "libvmdk-python")
197
+ }
198
+ PYTSK3_VERSION = "20260715"
199
+ PYTSK3_TSK_VERSION = "4.15.0"
200
+ PYTSK3_SDIST_SHA256 = "50825f97cb5b9bdf3119466104d78e9bef512dd0a0ce1615aea700ad2f457b8e"
201
+ SLEUTHKIT_ARCHIVE = {
202
+ "url": "https://codeload.github.com/sleuthkit/sleuthkit/tar.gz/refs/tags/sleuthkit-4.15.0",
203
+ "sha256": "4888ef54f9b404853712945218b3168696569db9167d7e01ec76e44b6c05c71c",
204
+ }
205
+ SLEUTHKIT_LICENSES = {
206
+ "IBM-LICENSE": "c44ddab37556dec46db7d443004b3479b298ef0518938c1449794292f2fd7765",
207
+ "cpl1.0.txt": "7b47225a5b77cb7825efc6a4d7e0ac57864c790542cf92f5080ec2bf310c00e5",
208
+ "bsd.txt": "7cdad98efc2f3aba2eefd732eafd7885a178bce0af7d7a5a89c22bd320c6532e",
209
+ "mit.txt": "6f1fcd5e7c1c516d831106390fa786896a37007860f784dd358422802a1fa184",
210
+ }
211
+ REQUIRED_NOTICE_SUFFIXES = {
212
+ "pytsk3": {
213
+ "talloc/license",
214
+ "sleuthkit/licenses/IBM-LICENSE",
215
+ "sleuthkit/licenses/cpl1.0.txt",
216
+ "sleuthkit/licenses/bsd.txt",
217
+ "sleuthkit/licenses/mit.txt",
218
+ "talloc/LICENSE",
219
+ }
220
+ }
221
+
222
+
223
+ def notices_complete(name: str, notices: list[str]) -> bool:
224
+ """Check all license texts required by a package and bundled native code."""
225
+ normalized = {str(path).replace("\\", "/").lower() for path in notices}
226
+ if name in REQUIRED_NOTICE_SUFFIXES:
227
+ apache_license = any(
228
+ path == "license" or path.endswith("/licenses/license")
229
+ for path in normalized
230
+ )
231
+ return apache_license and all(
232
+ any(path.endswith(suffix.lower()) for path in normalized)
233
+ for suffix in REQUIRED_NOTICE_SUFFIXES[name]
234
+ )
235
+ available = {Path(path).name.upper() for path in notices}
236
+ return bool(notices) and REQUIRED_NOTICES.get(name, set()) <= available
237
+
238
+
239
+ def validate_pytsk3_source(archive, source: dict) -> None:
240
+ """Tie the locked pytsk3 sdist to its vendored Sleuth Kit release."""
241
+ if source.get("sha256") != PYTSK3_SDIST_SHA256:
242
+ raise ValueError("Unexpected pytsk3 source checksum; update bundled license provenance")
243
+ updater = next(
244
+ (member for member in archive.getmembers()
245
+ if member.isfile() and member.name.endswith("/utils/update_source.py")),
246
+ None,
247
+ )
248
+ if updater is None:
249
+ raise ValueError("pytsk3 source is missing utils/update_source.py")
250
+ data = archive.extractfile(updater).read().decode("utf-8")
251
+ match = re.search(r'SLEUTHKIT_GIT_TAG\s*=\s*["\']([^"\']+)["\']', data)
252
+ if not match or match.group(1) != PYTSK3_TSK_VERSION:
253
+ raise ValueError("pytsk3 source no longer bundles the pinned Sleuth Kit version")
254
+
255
+
256
+ def collect_sleuthkit_notices(destination: Path) -> list[str]:
257
+ """Copy license texts from the checksum-pinned Sleuth Kit 4.15.0 archive."""
258
+ resource_dir = (
259
+ Path(__file__).resolve().parent / "vendor" /
260
+ f"sleuthkit-{PYTSK3_TSK_VERSION}" / "licenses"
261
+ )
262
+ collected = []
263
+ for filename, expected_hash in SLEUTHKIT_LICENSES.items():
264
+ data = (resource_dir / filename).read_bytes()
265
+ if hashlib.sha256(data).hexdigest() != expected_hash:
266
+ raise ValueError(f"Vendored Sleuth Kit license checksum mismatch: {filename}")
267
+ relative = Path("sleuthkit") / "licenses" / filename
268
+ target = destination / relative
269
+ target.parent.mkdir(parents=True, exist_ok=True)
270
+ target.write_bytes(data)
271
+ collected.append(str(relative))
272
+ return collected
273
+
274
+
275
+ def collect_python(output: Path) -> dict:
276
+ """Copy the original license shipped with the interpreter Nuitka embeds."""
277
+ stdlib = Path(sysconfig.get_path("stdlib"))
278
+ prefix = Path(sys.base_prefix)
279
+ candidates = (
280
+ stdlib / "LICENSE.txt",
281
+ prefix / "LICENSE.txt", # Windows and python.org installations.
282
+ stdlib / "LICENSE",
283
+ prefix / "LICENSE",
284
+ )
285
+ source = next((path for path in candidates if path.is_file()), None)
286
+ if source is None:
287
+ raise ValueError("The running Python installation has no license file")
288
+ version = ".".join(map(str, sys.version_info[:3]))
289
+ destination = output / f"Python-{version}"
290
+ destination.mkdir(parents=True, exist_ok=True)
291
+ data = source.read_bytes()
292
+ (destination / "LICENSE.txt").write_bytes(data)
293
+ return {
294
+ "name": "Python",
295
+ "version": version,
296
+ "implementation": sys.implementation.name,
297
+ "notices": ["LICENSE.txt"],
298
+ "source": {"path": str(source), "sha256": hashlib.sha256(data).hexdigest()},
299
+ }
300
+
301
+
302
+ def get_source(name: str, version: str):
303
+ with urlopen(f"https://pypi.org/pypi/{name}/{version}/json", timeout=60) as response:
304
+ metadata = json.load(response)
305
+ source = next(item for item in metadata["urls"] if item["packagetype"] == "sdist")
306
+ with urlopen(source["url"], timeout=120) as response:
307
+ data = response.read()
308
+ checksum = hashlib.sha256(data).hexdigest()
309
+ if checksum != source["digests"]["sha256"]:
310
+ raise ValueError(f"Source checksum mismatch: {name} {version}")
311
+ return tarfile.open(fileobj=io.BytesIO(data)), {"url": source["url"], "sha256": checksum}
312
+
313
+
314
+ def collect(name: str, output: Path) -> dict:
315
+ dist = distribution(name)
316
+ if name == "pytsk3" and dist.version != PYTSK3_VERSION:
317
+ raise ValueError(f"pytsk3 {PYTSK3_VERSION} is required for pinned Sleuth Kit notices")
318
+ destination = output / f"{name}-{dist.version}"
319
+ destination.mkdir(parents=True, exist_ok=True)
320
+ notices = []
321
+ for path in dist.files or []:
322
+ if name == "pytsk3":
323
+ # Use only notices from the verified sdist and pinned TSK archive below.
324
+ continue
325
+ if is_notice(str(path)):
326
+ # Keep original relative paths to preserve notices with the same basename.
327
+ relative = Path(*Path(str(path)).parts)
328
+ if relative.is_absolute() or ".." in relative.parts:
329
+ continue
330
+ target = destination / relative
331
+ target.parent.mkdir(parents=True, exist_ok=True)
332
+ target.write_bytes(Path(dist.locate_file(path)).read_bytes())
333
+ notices.append(str(relative))
334
+ record = {"name": name, "version": dist.version, "notices": notices}
335
+ if notices_complete(name, notices) and name not in {"pytsk3", "mft"}:
336
+ return record
337
+ archive, source = get_source(name, dist.version)
338
+ record["source"] = source
339
+ with archive:
340
+ if name == "pytsk3":
341
+ validate_pytsk3_source(archive, source)
342
+ for member in archive.getmembers():
343
+ if not member.isfile() or not is_notice(member.name):
344
+ continue
345
+ relative = Path(*Path(member.name).parts[1:])
346
+ if relative.is_absolute() or ".." in relative.parts:
347
+ raise ValueError(f"Unsafe source member: {member.name}")
348
+ target = destination / relative
349
+ target.parent.mkdir(parents=True, exist_ok=True)
350
+ target.write_bytes(archive.extractfile(member).read())
351
+ notices.append(str(relative))
352
+ if name == "pytsk3":
353
+ notices.extend(collect_sleuthkit_notices(destination))
354
+ notices = list(dict.fromkeys(notices))
355
+ record["notices"] = notices
356
+ record["native_component"] = {
357
+ "name": "Sleuth Kit",
358
+ "version": PYTSK3_TSK_VERSION,
359
+ "source": SLEUTHKIT_ARCHIVE,
360
+ "license_catalog": "Sleuth Kit 4.15.0 licenses/README.md: libtsk uses IBM, CPL, MIT, and BSD-3-Clause licensed code",
361
+ }
362
+ if not notices and name == "mft":
363
+ # pymft-rs 0.10.0 declares MIT but omits a full license file in both
364
+ # wheel and sdist. Retain its actual declaration and author metadata
365
+ # alongside the standard MIT terms; do not infer copyright ownership.
366
+ project_member = next(m for m in archive if m.name.count("/") == 1 and m.name.endswith("/pyproject.toml"))
367
+ cargo_member = next(m for m in archive if m.name.count("/") == 1 and m.name.endswith("/Cargo.toml"))
368
+ project_data = archive.extractfile(project_member).read()
369
+ cargo_data = archive.extractfile(cargo_member).read()
370
+ project = tomllib.loads(project_data.decode())
371
+ cargo = tomllib.loads(cargo_data.decode())
372
+ if project["project"]["license"] != "MIT":
373
+ raise ValueError("mft no longer declares MIT; update the notice collector")
374
+ authors = [re.sub(r"\s*<[^>]*>", "", author) for author in cargo["package"]["authors"]]
375
+ (destination / "pyproject.toml").write_bytes(project_data)
376
+ (destination / "Cargo.toml").write_bytes(cargo_data)
377
+ notice = "Declared license: MIT\nPackage authors (Cargo.toml): " + ", ".join(authors) + "\n\n" + MIT_PERMISSION
378
+ (destination / "LICENSE-MIT.txt").write_text(notice)
379
+ notices.extend(["LICENSE-MIT.txt", "pyproject.toml", "Cargo.toml"])
380
+ record["notice_provenance"] = "MIT declaration and author names from verified sdist; standard MIT permission text"
381
+ if name == "mft":
382
+ # Collect locked Rust notices even if a future mft wheel adds its
383
+ # own root license file. The lock is read from the verified sdist.
384
+ lock_member = next(m for m in archive if m.name.count("/") == 1 and m.name.endswith("/Cargo.lock"))
385
+ lock_data = archive.extractfile(lock_member).read()
386
+ rust_notices, rust_crates = collect_locked_rust_notices(lock_data, destination)
387
+ notices.extend(rust_notices)
388
+ (destination / "Cargo.lock").write_bytes(lock_data)
389
+ notices.append("Cargo.lock")
390
+ record["rust_dependencies"] = {
391
+ "scope": "all registry packages in the verified Cargo.lock, including build, development, and platform-specific dependencies",
392
+ "lockfile_sha256": hashlib.sha256(lock_data).hexdigest(),
393
+ "count": len(rust_crates),
394
+ "crates": rust_crates,
395
+ }
396
+ if not notices_complete(name, notices):
397
+ normalized = {str(path).replace("\\", "/").lower() for path in notices}
398
+ missing = {
399
+ suffix for suffix in REQUIRED_NOTICE_SUFFIXES.get(name, set())
400
+ if not any(path.endswith(suffix.lower()) for path in normalized)
401
+ }
402
+ missing |= REQUIRED_NOTICES.get(name, set()) - {Path(path).name.upper() for path in notices}
403
+ raise ValueError(
404
+ f"Incomplete license notices for {name} {dist.version}: "
405
+ f"{', '.join(sorted(missing)) or 'no notices found'}"
406
+ )
407
+ return record
408
+
409
+
410
+ def main():
411
+ project, target = sys.argv[1:]
412
+ if project not in {"ntfsdump", "ntfsfind"}:
413
+ raise ValueError("Expected ntfsdump or ntfsfind")
414
+ output = Path(target)
415
+ output.mkdir(parents=True, exist_ok=True)
416
+ packages = ["ntfsdump", "libewf-python", "libvhdi-python", "libvmdk-python", "pytsk3"]
417
+ if project == "ntfsfind":
418
+ packages += ["ntfsfind", "mft"]
419
+ records = [collect(package, output) for package in packages]
420
+ records.append(collect_python(output))
421
+ (output / "manifest.json").write_text(json.dumps(records, indent=2) + "\n")
422
+ print(f"Collected notices for {len(records)} components in {output}")
423
+
424
+
425
+ if __name__ == "__main__":
426
+ main()