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.
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/.devcontainer/Dockerfile +8 -5
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/.devcontainer/devcontainer.json +8 -5
- ntfsdump-3.3.5/.devcontainer/postCreateCommands.fish +3 -0
- ntfsdump-3.3.5/.dockerignore +16 -0
- ntfsdump-3.3.5/.flake8 +3 -0
- ntfsdump-3.3.5/.gitattributes +2 -0
- ntfsdump-3.3.5/.github/scripts/collect_licenses.py +426 -0
- ntfsdump-3.3.5/.github/scripts/vendor/sleuthkit-4.15.0/licenses/IBM-LICENSE +221 -0
- ntfsdump-3.3.5/.github/scripts/vendor/sleuthkit-4.15.0/licenses/bsd.txt +16 -0
- ntfsdump-3.3.5/.github/scripts/vendor/sleuthkit-4.15.0/licenses/cpl1.0.txt +213 -0
- ntfsdump-3.3.5/.github/scripts/vendor/sleuthkit-4.15.0/licenses/mit.txt +24 -0
- ntfsdump-3.3.5/.github/workflows/publish-release.yaml +131 -0
- ntfsdump-3.3.5/.github/workflows/test.yaml +65 -0
- ntfsdump-3.3.5/Dockerfile +22 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/PKG-INFO +36 -4
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/README.md +34 -2
- ntfsdump-3.3.5/docker-compose.yml +8 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/pyproject.toml +15 -7
- ntfsdump-3.3.5/src/ntfsdump/__about__.py +2 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/__init__.py +1 -1
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/cli.py +100 -58
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/core.py +13 -4
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/__init__.py +7 -8
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/base.py +16 -2
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/detect.py +11 -12
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/e01.py +3 -2
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/raw.py +1 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/vhd.py +19 -16
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/formats/vmdk.py +29 -13
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/image.py +74 -28
- ntfsdump-3.3.5/src/ntfsdump/image_paths.py +45 -0
- ntfsdump-3.3.5/src/ntfsdump/logger.py +173 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/sources/resolver.py +17 -19
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/vmware/metadata.py +53 -45
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/volume.py +94 -49
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_core.py +43 -0
- ntfsdump-3.3.5/tests/ntfsdump/test_directory_ads_real.py +393 -0
- ntfsdump-3.3.5/tests/ntfsdump/test_e01.py +31 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_image_os_volume.py +1 -1
- ntfsdump-3.3.5/tests/ntfsdump/test_logger.py +127 -0
- ntfsdump-3.3.5/tests/ntfsdump/test_partition_image.py +365 -0
- ntfsdump-3.3.5/tests/ntfsdump/test_path_fidelity.py +89 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vhd_real.py +10 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vhdx_real.py +51 -5
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vmdk_chain.py +40 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vmware.py +15 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_volume.py +278 -180
- ntfsdump-3.3.5/tests/test_license_collection.py +371 -0
- ntfsdump-3.3.5/uv.lock +645 -0
- ntfsdump-3.3.2/.devcontainer/postCreateCommands.fish +0 -2
- ntfsdump-3.3.2/.github/workflows/publish-binary-pypi.yaml +0 -25
- ntfsdump-3.3.2/.github/workflows/publish-release.yaml +0 -145
- ntfsdump-3.3.2/.github/workflows/test.yaml +0 -52
- ntfsdump-3.3.2/Dockerfile +0 -14
- ntfsdump-3.3.2/docker-compose.yml +0 -18
- ntfsdump-3.3.2/src/ntfsdump/__about__.py +0 -2
- ntfsdump-3.3.2/src/ntfsdump/image_paths.py +0 -22
- ntfsdump-3.3.2/src/ntfsdump/logger.py +0 -105
- ntfsdump-3.3.2/uv.lock +0 -527
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/.gitignore +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/LICENSE +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/__main__.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/errors.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/sources/__init__.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/src/ntfsdump/vmware/__init__.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/cache/.gitkeep +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/conftest.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/fixtures/vmware/SingleVM/SingleVM.vmx +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/fixtures/vmware/WindowsVM/WindowsVM.vmsd +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/fixtures/vmware/WindowsVM/WindowsVM.vmx +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test__init__.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_cli.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_detect.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_source.py +0 -0
- {ntfsdump-3.3.2 → ntfsdump-3.3.5}/tests/ntfsdump/test_vhdx_chain.py +0 -0
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
# syntax=docker/dockerfile:1
|
|
2
|
+
FROM python:3.13.16-slim-trixie
|
|
2
3
|
|
|
3
|
-
RUN apt
|
|
4
|
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
4
5
|
curl \
|
|
5
|
-
|
|
6
|
+
build-essential \
|
|
6
7
|
git \
|
|
7
8
|
fish \
|
|
8
9
|
wget \
|
|
9
|
-
&& apt
|
|
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
|
|
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.
|
|
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":
|
|
21
|
+
"source.organizeImports": "explicit"
|
|
19
22
|
}
|
|
20
23
|
}
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
26
|
}
|
|
24
|
-
}
|
|
27
|
+
}
|
ntfsdump-3.3.5/.flake8
ADDED
|
@@ -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()
|