sci-protocol 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pysci/__init__.py +77 -0
- pysci/__main__.py +8 -0
- pysci/_cli/__init__.py +5 -0
- pysci/_cli/checks.py +173 -0
- pysci/_cli/clients/__init__.py +0 -0
- pysci/_cli/clients/archive.py +456 -0
- pysci/_cli/clients/credentials.py +54 -0
- pysci/_cli/clients/github.py +734 -0
- pysci/_cli/clients/hosting.py +238 -0
- pysci/_cli/clients/http.py +481 -0
- pysci/_cli/clients/huggingface.py +492 -0
- pysci/_cli/clients/index.py +435 -0
- pysci/_cli/clients/tsa.py +223 -0
- pysci/_cli/commands/__init__.py +44 -0
- pysci/_cli/commands/check.py +115 -0
- pysci/_cli/commands/claim.py +179 -0
- pysci/_cli/commands/common.py +91 -0
- pysci/_cli/commands/folders.py +83 -0
- pysci/_cli/commands/index.py +51 -0
- pysci/_cli/commands/keygen.py +58 -0
- pysci/_cli/commands/license.py +75 -0
- pysci/_cli/commands/protocol.py +72 -0
- pysci/_cli/commands/publish.py +282 -0
- pysci/_cli/commands/pull.py +337 -0
- pysci/_cli/commands/read.py +364 -0
- pysci/_cli/commands/revoke.py +207 -0
- pysci/_cli/commands/search.py +400 -0
- pysci/_cli/commands/sign.py +284 -0
- pysci/_cli/commands/submit.py +172 -0
- pysci/_cli/commands/timestamp.py +196 -0
- pysci/_cli/commands/verify.py +163 -0
- pysci/_cli/config.py +233 -0
- pysci/_cli/context.py +79 -0
- pysci/_cli/destinations.py +163 -0
- pysci/_cli/errors.py +168 -0
- pysci/_cli/main.py +229 -0
- pysci/_cli/output.py +80 -0
- pysci/_cli/retrieval.py +51 -0
- pysci/_cli/spec.py +113 -0
- pysci/_cli/storage/__init__.py +0 -0
- pysci/_cli/storage/catalog.py +371 -0
- pysci/_cli/storage/files.py +252 -0
- pysci/_cli/storage/inputs.py +205 -0
- pysci/_cli/storage/keys.py +132 -0
- pysci/_cli/storage/local_publish.py +88 -0
- pysci/accounts.py +135 -0
- pysci/baseline.py +142 -0
- pysci/bundle.py +878 -0
- pysci/citations.py +233 -0
- pysci/data/freetsa-cacert.pem +45 -0
- pysci/data/sci-baseline-LICENSE.txt +457 -0
- pysci/data/sci-protocol.md +613 -0
- pysci/der.py +146 -0
- pysci/discovery.py +461 -0
- pysci/draft.py +374 -0
- pysci/ed25519.py +153 -0
- pysci/errors.py +204 -0
- pysci/ids.py +61 -0
- pysci/jsonstrict.py +225 -0
- pysci/keys.py +247 -0
- pysci/manifest.py +302 -0
- pysci/metadata.py +406 -0
- pysci/paths.py +136 -0
- pysci/png.py +217 -0
- pysci/py.typed +0 -0
- pysci/records.py +384 -0
- pysci/rfc3339.py +66 -0
- pysci/schemas.py +75 -0
- pysci/source.py +166 -0
- pysci/specification.py +58 -0
- pysci/submission.py +225 -0
- pysci/templates.py +129 -0
- pysci/text.py +226 -0
- pysci/timestamps.py +867 -0
- pysci/zipformat.py +400 -0
- sci_protocol-0.2.0.dist-info/METADATA +112 -0
- sci_protocol-0.2.0.dist-info/RECORD +80 -0
- sci_protocol-0.2.0.dist-info/WHEEL +4 -0
- sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
- sci_protocol-0.2.0.dist-info/licenses/LICENSE +202 -0
pysci/bundle.py
ADDED
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
"""Publication verification: manifest, file, metadata, and complete bundle checks (§6.1-6.4).
|
|
2
|
+
|
|
3
|
+
Entry points:
|
|
4
|
+
|
|
5
|
+
* :func:`verify_zip` -- a single-file ZIP bundle (any file name);
|
|
6
|
+
* :func:`verify_loose` -- loose files given as a path -> bytes/source mapping;
|
|
7
|
+
* :func:`verify_directory` / :func:`verify_path` -- local files on disk;
|
|
8
|
+
* :func:`verify_file` -- one listed file against a verified manifest (§6.2).
|
|
9
|
+
|
|
10
|
+
Results never raise for protocol failures; they return a
|
|
11
|
+
:class:`VerificationResult` whose :attr:`~VerificationResult.outcome`
|
|
12
|
+
distinguishes ``invalid`` from ``unsupported`` and ``incomplete`` (§6).
|
|
13
|
+
Verification never extracts, decodes, or executes payload bytes before they
|
|
14
|
+
pass their hash checks; large files are streamed in bounded chunks.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import stat
|
|
22
|
+
import zlib
|
|
23
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from enum import StrEnum
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Final
|
|
28
|
+
|
|
29
|
+
from .baseline import BaselineReport, evaluate_baseline
|
|
30
|
+
from .errors import Incomplete, Invalid, Issue, IssueCollector, Outcome, SciError
|
|
31
|
+
from .manifest import (
|
|
32
|
+
LICENSE_PATH,
|
|
33
|
+
MANIFEST_PATH,
|
|
34
|
+
METADATA_PATH,
|
|
35
|
+
README_PATH,
|
|
36
|
+
SIGNATURE_PATH,
|
|
37
|
+
TIMESTAMP_SIDECAR_PATH,
|
|
38
|
+
FileEntry,
|
|
39
|
+
Manifest,
|
|
40
|
+
verify_manifest,
|
|
41
|
+
)
|
|
42
|
+
from .metadata import Metadata, parse_metadata
|
|
43
|
+
from .paths import path_sort_key
|
|
44
|
+
from .png import PngStructure, display_problem, is_display_candidate
|
|
45
|
+
from .source import ByteSource, FileSource, MemorySource, PathSource, as_source, iter_chunks
|
|
46
|
+
from .text import TextFileValidator
|
|
47
|
+
from .zipformat import (
|
|
48
|
+
BASELINE_ZIP_LIMIT,
|
|
49
|
+
SupportsWrite,
|
|
50
|
+
ZipEntry,
|
|
51
|
+
canonical_zip_size,
|
|
52
|
+
parse_zip_layout,
|
|
53
|
+
write_canonical_zip,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = [
|
|
57
|
+
"Scope",
|
|
58
|
+
"VerificationLimits",
|
|
59
|
+
"VerificationResult",
|
|
60
|
+
"canonical_entries",
|
|
61
|
+
"extract_verified",
|
|
62
|
+
"manifest_zip_size",
|
|
63
|
+
"read_zip_entry",
|
|
64
|
+
"verify_directory",
|
|
65
|
+
"verify_file",
|
|
66
|
+
"verify_loose",
|
|
67
|
+
"verify_path",
|
|
68
|
+
"verify_zip",
|
|
69
|
+
"write_bundle_zip",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
_SIGNATURE_LIMIT: Final = 64
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Scope(StrEnum):
|
|
76
|
+
"""How much of a publication to verify."""
|
|
77
|
+
|
|
78
|
+
MANIFEST = "manifest"
|
|
79
|
+
"""§6.1 only (plus the planned ZIP size); payloads may be absent."""
|
|
80
|
+
COMPLETE = "complete"
|
|
81
|
+
"""§6.4 complete bundle checks."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class VerificationLimits:
|
|
86
|
+
"""Local resource limits. Exceeding one yields ``incomplete``, never ``invalid``.
|
|
87
|
+
|
|
88
|
+
The defaults accommodate every baseline publication with large margins
|
|
89
|
+
(§2.1 requires services to complete checks for baseline publications).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
max_input_bytes: int | None = 4 * 1024**3
|
|
93
|
+
"""Total input size (the ZIP, or the sum of loose files); None for unlimited."""
|
|
94
|
+
max_manifest_bytes: int = 16 * 1024**2
|
|
95
|
+
max_metadata_bytes: int = 16 * 1024**2
|
|
96
|
+
max_entries: int = 65534
|
|
97
|
+
max_central_directory_bytes: int = 64 * 1024**2
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
DEFAULT_LIMITS: Final = VerificationLimits()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class VerificationResult:
|
|
105
|
+
"""Outcome of a publication verification.
|
|
106
|
+
|
|
107
|
+
``baseline`` is a boolean only after complete successful verification;
|
|
108
|
+
otherwise None. ``zip_bytes`` is the canonical ZIP size (§2.1 formula)
|
|
109
|
+
once the manifest is verified. ``zip_sha256`` is the SHA-256 of the
|
|
110
|
+
canonical ZIP when it was computed (always for ZIP input).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
outcome: Outcome
|
|
114
|
+
scope: Scope
|
|
115
|
+
issues: tuple[Issue, ...] = ()
|
|
116
|
+
publication_id: str | None = None
|
|
117
|
+
manifest: Manifest | None = None
|
|
118
|
+
metadata: Metadata | None = None
|
|
119
|
+
baseline: bool | None = None
|
|
120
|
+
baseline_report: BaselineReport | None = None
|
|
121
|
+
zip_bytes: int | None = None
|
|
122
|
+
zip_sha256: str | None = None
|
|
123
|
+
checked_files: tuple[str, ...] = field(default_factory=tuple)
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def ok(self) -> bool:
|
|
127
|
+
return self.outcome is Outcome.VALID
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def within_baseline_zip_limit(self) -> bool | None:
|
|
131
|
+
return None if self.zip_bytes is None else self.zip_bytes <= BASELINE_ZIP_LIMIT
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def manifest_zip_size(manifest: Manifest, signature_bytes: int = 64) -> int:
|
|
135
|
+
"""§2.1 ZIP size computed from the manifest alone."""
|
|
136
|
+
sizes = [(MANIFEST_PATH, len(manifest.raw)), (SIGNATURE_PATH, signature_bytes)]
|
|
137
|
+
sizes.extend((path, entry.bytes) for path, entry in manifest.files.items())
|
|
138
|
+
return canonical_zip_size(sizes)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --------------------------------------------------------------------------- helpers
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _incomplete(
|
|
145
|
+
code: str, requirement: str, observed: object = None, file: str | None = None
|
|
146
|
+
) -> Incomplete:
|
|
147
|
+
return Incomplete(
|
|
148
|
+
Issue(
|
|
149
|
+
code,
|
|
150
|
+
requirement,
|
|
151
|
+
section="6",
|
|
152
|
+
file=file,
|
|
153
|
+
observed=None if observed is None else str(observed),
|
|
154
|
+
outcome=Outcome.INCOMPLETE,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _check_budget(total: int, limits: VerificationLimits) -> None:
|
|
160
|
+
if limits.max_input_bytes is not None and total > limits.max_input_bytes:
|
|
161
|
+
raise _incomplete(
|
|
162
|
+
"input_budget_exceeded",
|
|
163
|
+
"input exceeds the local verification budget",
|
|
164
|
+
f"{total} bytes > {limits.max_input_bytes}",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_small(source: ByteSource, limit: int, file: str) -> bytes:
|
|
169
|
+
if source.size > limit:
|
|
170
|
+
raise _incomplete(
|
|
171
|
+
"local_size_limit",
|
|
172
|
+
f"{file} exceeds the local size limit",
|
|
173
|
+
f"{source.size} bytes > {limit}",
|
|
174
|
+
file=file,
|
|
175
|
+
)
|
|
176
|
+
return source.read_at(0, source.size)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _FileDigest:
|
|
180
|
+
"""Streaming SHA-256 (and optional CRC-32) for one entry, with optional teeing."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, crc: bool) -> None:
|
|
183
|
+
self.sha = hashlib.sha256()
|
|
184
|
+
self.crc: int | None = 0 if crc else None
|
|
185
|
+
self.sinks: list[Callable[[bytes], object]] = []
|
|
186
|
+
|
|
187
|
+
def update(self, chunk: bytes) -> None:
|
|
188
|
+
self.sha.update(chunk)
|
|
189
|
+
if self.crc is not None:
|
|
190
|
+
self.crc = zlib.crc32(chunk, self.crc)
|
|
191
|
+
for sink in self.sinks:
|
|
192
|
+
sink(chunk)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class _PayloadChecks:
|
|
196
|
+
"""Collects metadata bytes and text-file validation while payloads stream."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, manifest: Manifest, limits: VerificationLimits) -> None:
|
|
199
|
+
self.manifest = manifest
|
|
200
|
+
self.limits = limits
|
|
201
|
+
self.metadata_chunks: list[bytes] = []
|
|
202
|
+
self.text: dict[str, TextFileValidator] = {
|
|
203
|
+
README_PATH: TextFileValidator(file=README_PATH, nonblank=True)
|
|
204
|
+
}
|
|
205
|
+
if LICENSE_PATH in manifest.files:
|
|
206
|
+
self.text[LICENSE_PATH] = TextFileValidator(file=LICENSE_PATH)
|
|
207
|
+
self.license_sha256: str | None = None
|
|
208
|
+
self.png: dict[str, PngStructure] = {}
|
|
209
|
+
|
|
210
|
+
def attach(self, path: str, digest: _FileDigest) -> None:
|
|
211
|
+
if path == METADATA_PATH:
|
|
212
|
+
size = self.manifest.files[path].bytes
|
|
213
|
+
if size > self.limits.max_metadata_bytes:
|
|
214
|
+
raise _incomplete(
|
|
215
|
+
"local_size_limit", "sci.json exceeds the local size limit", size, file=path
|
|
216
|
+
)
|
|
217
|
+
digest.sinks.append(self.metadata_chunks.append)
|
|
218
|
+
elif path in self.text:
|
|
219
|
+
digest.sinks.append(self.text[path].feed)
|
|
220
|
+
elif is_display_candidate(path, self.manifest.files[path].bytes):
|
|
221
|
+
# Display paths are known only after sci.json, which can follow the files.
|
|
222
|
+
self.png[path] = PngStructure()
|
|
223
|
+
digest.sinks.append(self.png[path].feed)
|
|
224
|
+
|
|
225
|
+
def display_problems(self, metadata: Metadata) -> dict[str, str]:
|
|
226
|
+
problems: dict[str, str] = {}
|
|
227
|
+
for item in metadata.display:
|
|
228
|
+
size = self.manifest.files[item.path].bytes
|
|
229
|
+
problem = display_problem(item.path, size, self.png.get(item.path))
|
|
230
|
+
if problem is not None:
|
|
231
|
+
problems[item.path] = problem
|
|
232
|
+
return problems
|
|
233
|
+
|
|
234
|
+
def finish(self, issues: IssueCollector) -> Metadata | None:
|
|
235
|
+
for validator in self.text.values():
|
|
236
|
+
issues.extend(validator.finish())
|
|
237
|
+
metadata = None
|
|
238
|
+
try:
|
|
239
|
+
metadata = parse_metadata(b"".join(self.metadata_chunks), self.manifest.files.keys())
|
|
240
|
+
except SciError as exc:
|
|
241
|
+
issues.extend(exc.issues)
|
|
242
|
+
return metadata
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _compare_digest(
|
|
246
|
+
path: str, entry: FileEntry, size: int, digest: _FileDigest, issues: IssueCollector
|
|
247
|
+
) -> bool:
|
|
248
|
+
if size != entry.bytes:
|
|
249
|
+
issues.add(
|
|
250
|
+
"file_size_mismatch",
|
|
251
|
+
"byte count must equal the manifest bytes value",
|
|
252
|
+
section="6.2",
|
|
253
|
+
file=path,
|
|
254
|
+
observed=f"{size} != {entry.bytes}",
|
|
255
|
+
)
|
|
256
|
+
return False
|
|
257
|
+
actual = digest.sha.hexdigest()
|
|
258
|
+
if actual != entry.sha256:
|
|
259
|
+
issues.add(
|
|
260
|
+
"file_hash_mismatch",
|
|
261
|
+
"SHA-256 must equal the manifest sha256 value",
|
|
262
|
+
section="6.2",
|
|
263
|
+
file=path,
|
|
264
|
+
observed=actual,
|
|
265
|
+
)
|
|
266
|
+
return False
|
|
267
|
+
return True
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _finish(
|
|
271
|
+
manifest: Manifest,
|
|
272
|
+
signature_size: int,
|
|
273
|
+
payload: _PayloadChecks,
|
|
274
|
+
issues: IssueCollector,
|
|
275
|
+
zip_sha256: str | None,
|
|
276
|
+
checked: Iterable[str],
|
|
277
|
+
) -> VerificationResult:
|
|
278
|
+
metadata = payload.finish(issues) if not issues else None
|
|
279
|
+
zip_bytes = manifest_zip_size(manifest, signature_size)
|
|
280
|
+
if issues:
|
|
281
|
+
return _failure(issues.issues, Scope.COMPLETE, manifest, zip_bytes)
|
|
282
|
+
assert metadata is not None
|
|
283
|
+
license_entry = manifest.files.get(LICENSE_PATH)
|
|
284
|
+
report = evaluate_baseline(
|
|
285
|
+
zip_bytes=zip_bytes,
|
|
286
|
+
file_count=len(manifest.files),
|
|
287
|
+
display_items=len(metadata.display),
|
|
288
|
+
manifest_bytes=len(manifest.raw),
|
|
289
|
+
metadata_bytes=manifest.files[METADATA_PATH].bytes,
|
|
290
|
+
readme_bytes=manifest.files[README_PATH].bytes,
|
|
291
|
+
license_sha256=None if license_entry is None else license_entry.sha256,
|
|
292
|
+
display_problems=payload.display_problems(metadata),
|
|
293
|
+
)
|
|
294
|
+
return VerificationResult(
|
|
295
|
+
outcome=Outcome.VALID,
|
|
296
|
+
scope=Scope.COMPLETE,
|
|
297
|
+
publication_id=manifest.publication_id,
|
|
298
|
+
manifest=manifest,
|
|
299
|
+
metadata=metadata,
|
|
300
|
+
baseline=report.eligible,
|
|
301
|
+
baseline_report=report,
|
|
302
|
+
zip_bytes=zip_bytes,
|
|
303
|
+
zip_sha256=zip_sha256,
|
|
304
|
+
checked_files=tuple(checked),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _failure(
|
|
309
|
+
issues: Iterable[Issue],
|
|
310
|
+
scope: Scope,
|
|
311
|
+
manifest: Manifest | None = None,
|
|
312
|
+
zip_bytes: int | None = None,
|
|
313
|
+
) -> VerificationResult:
|
|
314
|
+
issue_tuple = tuple(issues)
|
|
315
|
+
collector = IssueCollector(list(issue_tuple))
|
|
316
|
+
return VerificationResult(
|
|
317
|
+
outcome=collector.outcome,
|
|
318
|
+
scope=scope,
|
|
319
|
+
issues=issue_tuple,
|
|
320
|
+
publication_id=None if manifest is None else manifest.publication_id,
|
|
321
|
+
manifest=manifest,
|
|
322
|
+
zip_bytes=zip_bytes,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _manifest_only(manifest: Manifest, signature_size: int) -> VerificationResult:
|
|
327
|
+
return VerificationResult(
|
|
328
|
+
outcome=Outcome.VALID,
|
|
329
|
+
scope=Scope.MANIFEST,
|
|
330
|
+
publication_id=manifest.publication_id,
|
|
331
|
+
manifest=manifest,
|
|
332
|
+
zip_bytes=manifest_zip_size(manifest, signature_size),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _guard(scope: Scope, run: Callable[[], VerificationResult]) -> VerificationResult:
|
|
337
|
+
try:
|
|
338
|
+
return run()
|
|
339
|
+
except SciError as exc:
|
|
340
|
+
return _failure(exc.issues, scope)
|
|
341
|
+
except OSError as exc:
|
|
342
|
+
return _failure(
|
|
343
|
+
[
|
|
344
|
+
Issue(
|
|
345
|
+
"input_unavailable",
|
|
346
|
+
"input could not be read completely",
|
|
347
|
+
section="6",
|
|
348
|
+
observed=str(exc),
|
|
349
|
+
outcome=Outcome.INCOMPLETE,
|
|
350
|
+
)
|
|
351
|
+
],
|
|
352
|
+
scope,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# --------------------------------------------------------------------------- ZIP
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def verify_zip(
|
|
360
|
+
bundle: ByteSource | bytes | str | os.PathLike[str],
|
|
361
|
+
*,
|
|
362
|
+
expected_id: str | None = None,
|
|
363
|
+
scope: Scope = Scope.COMPLETE,
|
|
364
|
+
limits: VerificationLimits = DEFAULT_LIMITS,
|
|
365
|
+
) -> VerificationResult:
|
|
366
|
+
"""Verify a ZIP bundle under §2.2 and §6.4 (or §6.1 with ``Scope.MANIFEST``).
|
|
367
|
+
|
|
368
|
+
``bundle`` may be a path, bytes, or a :class:`~pysci.source.ByteSource`.
|
|
369
|
+
The file name is irrelevant (§2.2: renamed ZIPs remain verifiable).
|
|
370
|
+
"""
|
|
371
|
+
if isinstance(bundle, str | os.PathLike):
|
|
372
|
+
try:
|
|
373
|
+
source = FileSource(bundle)
|
|
374
|
+
except OSError as exc:
|
|
375
|
+
return _failure(
|
|
376
|
+
[
|
|
377
|
+
Issue(
|
|
378
|
+
"input_unavailable",
|
|
379
|
+
"bundle could not be opened",
|
|
380
|
+
section="6",
|
|
381
|
+
observed=str(exc),
|
|
382
|
+
outcome=Outcome.INCOMPLETE,
|
|
383
|
+
)
|
|
384
|
+
],
|
|
385
|
+
scope,
|
|
386
|
+
)
|
|
387
|
+
with source:
|
|
388
|
+
return _guard(scope, lambda: _verify_zip(source, expected_id, scope, limits))
|
|
389
|
+
src = as_source(bundle)
|
|
390
|
+
return _guard(scope, lambda: _verify_zip(src, expected_id, scope, limits))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _verify_zip(
|
|
394
|
+
source: ByteSource, expected_id: str | None, scope: Scope, limits: VerificationLimits
|
|
395
|
+
) -> VerificationResult:
|
|
396
|
+
_check_budget(source.size, limits)
|
|
397
|
+
entries = parse_zip_layout(
|
|
398
|
+
source, max_entries=limits.max_entries, max_central_bytes=limits.max_central_directory_bytes
|
|
399
|
+
)
|
|
400
|
+
names = [entry.name for entry in entries]
|
|
401
|
+
if names[:2] != [MANIFEST_PATH.encode(), SIGNATURE_PATH.encode()]:
|
|
402
|
+
raise Invalid(
|
|
403
|
+
Issue(
|
|
404
|
+
"zip_first_entries",
|
|
405
|
+
"the first two entries must be manifest.json and manifest.json.sig",
|
|
406
|
+
section="2.2",
|
|
407
|
+
file="bundle.zip",
|
|
408
|
+
observed=str([n.decode("ascii", "replace") for n in names[:2]]),
|
|
409
|
+
)
|
|
410
|
+
)
|
|
411
|
+
manifest_entry, signature_entry = entries[0], entries[1]
|
|
412
|
+
if manifest_entry.size > limits.max_manifest_bytes:
|
|
413
|
+
raise _incomplete(
|
|
414
|
+
"local_size_limit",
|
|
415
|
+
"manifest.json exceeds the local size limit",
|
|
416
|
+
manifest_entry.size,
|
|
417
|
+
file=MANIFEST_PATH,
|
|
418
|
+
)
|
|
419
|
+
manifest_bytes = source.read_at(manifest_entry.data_offset, manifest_entry.size)
|
|
420
|
+
signature = source.read_at(
|
|
421
|
+
signature_entry.data_offset, min(signature_entry.size, _SIGNATURE_LIMIT + 1)
|
|
422
|
+
)
|
|
423
|
+
_check_crc(manifest_bytes, manifest_entry)
|
|
424
|
+
if signature_entry.size <= _SIGNATURE_LIMIT:
|
|
425
|
+
_check_crc(signature, signature_entry)
|
|
426
|
+
manifest = verify_manifest(manifest_bytes, signature, expected_id=expected_id)
|
|
427
|
+
if scope is Scope.MANIFEST:
|
|
428
|
+
return _manifest_only(manifest, signature_entry.size)
|
|
429
|
+
_check_zip_inventory(entries[2:], manifest)
|
|
430
|
+
return _scan_zip(source, entries, manifest, limits)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _check_crc(data: bytes, entry: ZipEntry) -> None:
|
|
434
|
+
if zlib.crc32(data) != entry.crc32:
|
|
435
|
+
raise Invalid(
|
|
436
|
+
Issue("zip_crc", "CRC-32 must match the entry data", section="2.2", file=entry.path)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _check_zip_inventory(entries: list[ZipEntry], manifest: Manifest) -> None:
|
|
441
|
+
issues = IssueCollector()
|
|
442
|
+
paths = [entry.path for entry in entries]
|
|
443
|
+
seen: set[str] = set()
|
|
444
|
+
for path in paths:
|
|
445
|
+
if path in seen:
|
|
446
|
+
issues.add(
|
|
447
|
+
"zip_duplicate_entry",
|
|
448
|
+
"each listed file appears exactly once",
|
|
449
|
+
section="2.2",
|
|
450
|
+
file=path,
|
|
451
|
+
)
|
|
452
|
+
seen.add(path)
|
|
453
|
+
extra = sorted(seen - set(manifest.files))
|
|
454
|
+
missing = sorted(set(manifest.files) - seen)
|
|
455
|
+
if extra:
|
|
456
|
+
issues.add(
|
|
457
|
+
"bundle_extra_file",
|
|
458
|
+
"the bundle must contain only listed files plus the manifest and signature",
|
|
459
|
+
section="3",
|
|
460
|
+
observed=extra,
|
|
461
|
+
)
|
|
462
|
+
if missing:
|
|
463
|
+
issues.add(
|
|
464
|
+
"bundle_missing_file",
|
|
465
|
+
"every listed file must be present",
|
|
466
|
+
section="3",
|
|
467
|
+
observed=missing,
|
|
468
|
+
)
|
|
469
|
+
if not issues and paths != manifest.sorted_paths():
|
|
470
|
+
issues.add(
|
|
471
|
+
"zip_entry_order",
|
|
472
|
+
"listed entries must follow ascending ASCII path order",
|
|
473
|
+
section="2.2",
|
|
474
|
+
observed=paths[:5],
|
|
475
|
+
)
|
|
476
|
+
issues.raise_if_any()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _scan_zip(
|
|
480
|
+
source: ByteSource, entries: list[ZipEntry], manifest: Manifest, limits: VerificationLimits
|
|
481
|
+
) -> VerificationResult:
|
|
482
|
+
"""One sequential pass: whole-ZIP SHA-256, per-entry CRC-32 and SHA-256, text checks."""
|
|
483
|
+
whole = hashlib.sha256()
|
|
484
|
+
payload = _PayloadChecks(manifest, limits)
|
|
485
|
+
issues = IssueCollector()
|
|
486
|
+
position = 0
|
|
487
|
+
for index, entry in enumerate(entries):
|
|
488
|
+
whole.update(source.read_at(position, entry.data_offset - position))
|
|
489
|
+
digest = _FileDigest(crc=True)
|
|
490
|
+
if index >= 2:
|
|
491
|
+
payload.attach(entry.path, digest)
|
|
492
|
+
for chunk in iter_chunks(source, entry.data_offset, entry.size):
|
|
493
|
+
whole.update(chunk)
|
|
494
|
+
digest.update(chunk)
|
|
495
|
+
position = entry.data_offset + entry.size
|
|
496
|
+
if digest.crc != entry.crc32:
|
|
497
|
+
issues.add(
|
|
498
|
+
"zip_crc", "CRC-32 must match the entry data", section="2.2", file=entry.path
|
|
499
|
+
)
|
|
500
|
+
continue
|
|
501
|
+
if index >= 2:
|
|
502
|
+
_compare_digest(entry.path, manifest.files[entry.path], entry.size, digest, issues)
|
|
503
|
+
for chunk in iter_chunks(source, position, source.size - position):
|
|
504
|
+
whole.update(chunk)
|
|
505
|
+
return _finish(
|
|
506
|
+
manifest,
|
|
507
|
+
entries[1].size,
|
|
508
|
+
payload,
|
|
509
|
+
issues,
|
|
510
|
+
whole.hexdigest(),
|
|
511
|
+
[entry.path for entry in entries[2:]],
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# --------------------------------------------------------------------------- loose
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def verify_loose(
|
|
519
|
+
files: Mapping[str, ByteSource | bytes],
|
|
520
|
+
*,
|
|
521
|
+
expected_id: str | None = None,
|
|
522
|
+
scope: Scope = Scope.COMPLETE,
|
|
523
|
+
limits: VerificationLimits = DEFAULT_LIMITS,
|
|
524
|
+
compute_zip_sha256: bool = False,
|
|
525
|
+
) -> VerificationResult:
|
|
526
|
+
"""Verify loose bundle files given as ``{relative path: bytes or source}``.
|
|
527
|
+
|
|
528
|
+
The optional ``manifest.json.sig.tsr`` sidecar is excluded from the
|
|
529
|
+
inventory (§6.4). With ``compute_zip_sha256`` the canonical ZIP is
|
|
530
|
+
serialized into a hash (not stored) after successful verification.
|
|
531
|
+
"""
|
|
532
|
+
sources = {path: as_source(value) for path, value in files.items()}
|
|
533
|
+
return _guard(
|
|
534
|
+
scope, lambda: _verify_loose(sources, expected_id, scope, limits, compute_zip_sha256)
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _verify_loose(
|
|
539
|
+
sources: dict[str, ByteSource],
|
|
540
|
+
expected_id: str | None,
|
|
541
|
+
scope: Scope,
|
|
542
|
+
limits: VerificationLimits,
|
|
543
|
+
compute_zip_sha256: bool,
|
|
544
|
+
) -> VerificationResult:
|
|
545
|
+
_check_budget(sum(source.size for source in sources.values()), limits)
|
|
546
|
+
missing = [p for p in (MANIFEST_PATH, SIGNATURE_PATH) if p not in sources]
|
|
547
|
+
if missing:
|
|
548
|
+
raise Invalid(
|
|
549
|
+
Issue(
|
|
550
|
+
"bundle_missing_file",
|
|
551
|
+
"manifest.json and manifest.json.sig are required",
|
|
552
|
+
section="2",
|
|
553
|
+
observed=str(missing),
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
manifest_bytes = _read_small(sources[MANIFEST_PATH], limits.max_manifest_bytes, MANIFEST_PATH)
|
|
557
|
+
signature_source = sources[SIGNATURE_PATH]
|
|
558
|
+
signature = signature_source.read_at(0, min(signature_source.size, _SIGNATURE_LIMIT + 1))
|
|
559
|
+
manifest = verify_manifest(manifest_bytes, signature, expected_id=expected_id)
|
|
560
|
+
if scope is Scope.MANIFEST:
|
|
561
|
+
return _manifest_only(manifest, signature_source.size)
|
|
562
|
+
present = set(sources) - {MANIFEST_PATH, SIGNATURE_PATH, TIMESTAMP_SIDECAR_PATH}
|
|
563
|
+
issues = IssueCollector()
|
|
564
|
+
extra = sorted(present - set(manifest.files))
|
|
565
|
+
absent = sorted(set(manifest.files) - present)
|
|
566
|
+
if extra:
|
|
567
|
+
issues.add(
|
|
568
|
+
"bundle_extra_file",
|
|
569
|
+
"the bundle must contain only listed files plus the manifest and signature",
|
|
570
|
+
section="3",
|
|
571
|
+
observed=extra,
|
|
572
|
+
)
|
|
573
|
+
if absent:
|
|
574
|
+
issues.add(
|
|
575
|
+
"bundle_missing_file", "every listed file must be present", section="3", observed=absent
|
|
576
|
+
)
|
|
577
|
+
issues.raise_if_any()
|
|
578
|
+
payload = _PayloadChecks(manifest, limits)
|
|
579
|
+
for path in manifest.sorted_paths():
|
|
580
|
+
source = sources[path]
|
|
581
|
+
entry = manifest.files[path]
|
|
582
|
+
if source.size != entry.bytes: # size check before reading (§6.2)
|
|
583
|
+
issues.add(
|
|
584
|
+
"file_size_mismatch",
|
|
585
|
+
"byte count must equal the manifest bytes value",
|
|
586
|
+
section="6.2",
|
|
587
|
+
file=path,
|
|
588
|
+
observed=f"{source.size} != {entry.bytes}",
|
|
589
|
+
)
|
|
590
|
+
continue
|
|
591
|
+
digest = _FileDigest(crc=False)
|
|
592
|
+
payload.attach(path, digest)
|
|
593
|
+
for chunk in iter_chunks(source):
|
|
594
|
+
digest.update(chunk)
|
|
595
|
+
_compare_digest(path, entry, source.size, digest, issues)
|
|
596
|
+
zip_sha256 = None
|
|
597
|
+
if compute_zip_sha256 and not issues:
|
|
598
|
+
hasher = _HashSink()
|
|
599
|
+
write_canonical_zip(
|
|
600
|
+
hasher,
|
|
601
|
+
canonical_entries(manifest_bytes, signature, {p: sources[p] for p in manifest.files}),
|
|
602
|
+
)
|
|
603
|
+
zip_sha256 = hasher.hexdigest()
|
|
604
|
+
return _finish(
|
|
605
|
+
manifest, signature_source.size, payload, issues, zip_sha256, manifest.sorted_paths()
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class _HashSink:
|
|
610
|
+
def __init__(self) -> None:
|
|
611
|
+
self._hash = hashlib.sha256()
|
|
612
|
+
|
|
613
|
+
def write(self, data: bytes) -> int:
|
|
614
|
+
self._hash.update(data)
|
|
615
|
+
return len(data)
|
|
616
|
+
|
|
617
|
+
def hexdigest(self) -> str:
|
|
618
|
+
return self._hash.hexdigest()
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _scan_directory(root: Path) -> tuple[dict[str, ByteSource], list[Issue]]:
|
|
622
|
+
"""Map every regular file under ``root`` to a source; report other file types."""
|
|
623
|
+
sources: dict[str, ByteSource] = {}
|
|
624
|
+
problems: list[Issue] = []
|
|
625
|
+
stack = [root]
|
|
626
|
+
while stack:
|
|
627
|
+
directory = stack.pop()
|
|
628
|
+
with os.scandir(directory) as iterator:
|
|
629
|
+
for item in iterator:
|
|
630
|
+
relative = Path(item.path).relative_to(root).as_posix()
|
|
631
|
+
info = item.stat(follow_symlinks=False)
|
|
632
|
+
if stat.S_ISDIR(info.st_mode):
|
|
633
|
+
stack.append(Path(item.path))
|
|
634
|
+
elif stat.S_ISREG(info.st_mode):
|
|
635
|
+
sources[relative] = PathSource(item.path, info.st_size)
|
|
636
|
+
else:
|
|
637
|
+
problems.append(
|
|
638
|
+
Issue(
|
|
639
|
+
"not_regular_file",
|
|
640
|
+
"bundle files must be regular "
|
|
641
|
+
"files (no symbolic links or special files)",
|
|
642
|
+
section="6.4",
|
|
643
|
+
file=relative,
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
return sources, problems
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def verify_directory(
|
|
650
|
+
directory: str | os.PathLike[str],
|
|
651
|
+
*,
|
|
652
|
+
expected_id: str | None = None,
|
|
653
|
+
scope: Scope = Scope.COMPLETE,
|
|
654
|
+
limits: VerificationLimits = DEFAULT_LIMITS,
|
|
655
|
+
compute_zip_sha256: bool = False,
|
|
656
|
+
) -> VerificationResult:
|
|
657
|
+
"""Verify a signed directory of loose files (symbolic links are rejected)."""
|
|
658
|
+
try:
|
|
659
|
+
sources, problems = _scan_directory(Path(directory))
|
|
660
|
+
except OSError as exc:
|
|
661
|
+
return _failure(
|
|
662
|
+
[
|
|
663
|
+
Issue(
|
|
664
|
+
"input_unavailable",
|
|
665
|
+
"directory could not be read",
|
|
666
|
+
section="6",
|
|
667
|
+
observed=str(exc),
|
|
668
|
+
outcome=Outcome.INCOMPLETE,
|
|
669
|
+
)
|
|
670
|
+
],
|
|
671
|
+
scope,
|
|
672
|
+
)
|
|
673
|
+
if problems and scope is Scope.COMPLETE:
|
|
674
|
+
return _failure(problems, scope)
|
|
675
|
+
if scope is Scope.MANIFEST:
|
|
676
|
+
sources = {k: v for k, v in sources.items() if k in (MANIFEST_PATH, SIGNATURE_PATH)}
|
|
677
|
+
return verify_loose(
|
|
678
|
+
sources,
|
|
679
|
+
expected_id=expected_id,
|
|
680
|
+
scope=scope,
|
|
681
|
+
limits=limits,
|
|
682
|
+
compute_zip_sha256=compute_zip_sha256,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def verify_path(
|
|
687
|
+
path: str | os.PathLike[str],
|
|
688
|
+
*,
|
|
689
|
+
expected_id: str | None = None,
|
|
690
|
+
scope: Scope = Scope.COMPLETE,
|
|
691
|
+
limits: VerificationLimits = DEFAULT_LIMITS,
|
|
692
|
+
) -> VerificationResult:
|
|
693
|
+
"""Verify a directory (loose files) or a file (ZIP bundle)."""
|
|
694
|
+
if Path(path).is_dir():
|
|
695
|
+
return verify_directory(path, expected_id=expected_id, scope=scope, limits=limits)
|
|
696
|
+
return verify_zip(path, expected_id=expected_id, scope=scope, limits=limits)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
# --------------------------------------------------------------------------- single files
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def read_zip_entry(
|
|
703
|
+
bundle: ByteSource | bytes,
|
|
704
|
+
path: str,
|
|
705
|
+
*,
|
|
706
|
+
expected_id: str | None = None,
|
|
707
|
+
limits: VerificationLimits = DEFAULT_LIMITS,
|
|
708
|
+
) -> tuple[Manifest, bytes]:
|
|
709
|
+
"""Read one listed file from a ZIP after §6.1 and §6.2 checks (no complete check).
|
|
710
|
+
|
|
711
|
+
Only the end record, central directory, local headers, manifest,
|
|
712
|
+
signature, and the requested entry are read, so ``bundle`` may be a
|
|
713
|
+
range-request-backed :class:`~pysci.source.ByteSource`. This does not
|
|
714
|
+
establish that the whole bundle is valid (§2.2: verify completely before
|
|
715
|
+
unpacking the whole bundle).
|
|
716
|
+
|
|
717
|
+
Raises:
|
|
718
|
+
Invalid, Unsupported, Incomplete: a failed or impossible check.
|
|
719
|
+
"""
|
|
720
|
+
source = as_source(bundle)
|
|
721
|
+
entries = parse_zip_layout(
|
|
722
|
+
source,
|
|
723
|
+
max_entries=limits.max_entries,
|
|
724
|
+
max_central_bytes=limits.max_central_directory_bytes,
|
|
725
|
+
)
|
|
726
|
+
if [e.name for e in entries[:2]] != [MANIFEST_PATH.encode(), SIGNATURE_PATH.encode()]:
|
|
727
|
+
raise Invalid(
|
|
728
|
+
Issue(
|
|
729
|
+
"zip_first_entries",
|
|
730
|
+
"the first two entries must be manifest.json and manifest.json.sig",
|
|
731
|
+
section="2.2",
|
|
732
|
+
file="bundle.zip",
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
if entries[0].size > limits.max_manifest_bytes:
|
|
736
|
+
raise _incomplete(
|
|
737
|
+
"local_size_limit",
|
|
738
|
+
"manifest.json exceeds the local size limit",
|
|
739
|
+
entries[0].size,
|
|
740
|
+
file=MANIFEST_PATH,
|
|
741
|
+
)
|
|
742
|
+
manifest_bytes = source.read_at(entries[0].data_offset, entries[0].size)
|
|
743
|
+
signature = source.read_at(entries[1].data_offset, min(entries[1].size, _SIGNATURE_LIMIT + 1))
|
|
744
|
+
manifest = verify_manifest(manifest_bytes, signature, expected_id=expected_id)
|
|
745
|
+
matches = [e for e in entries[2:] if e.name == path.encode("utf-8")]
|
|
746
|
+
if path not in manifest.files or len(matches) != 1:
|
|
747
|
+
raise Invalid(
|
|
748
|
+
Issue(
|
|
749
|
+
"file_not_listed",
|
|
750
|
+
"requested path must be a listed ZIP entry",
|
|
751
|
+
section="6.2",
|
|
752
|
+
file=path,
|
|
753
|
+
)
|
|
754
|
+
)
|
|
755
|
+
entry = matches[0]
|
|
756
|
+
if entry.size != manifest.files[path].bytes:
|
|
757
|
+
raise Invalid(
|
|
758
|
+
Issue(
|
|
759
|
+
"file_size_mismatch",
|
|
760
|
+
"byte count must equal the manifest bytes value",
|
|
761
|
+
section="6.2",
|
|
762
|
+
file=path,
|
|
763
|
+
)
|
|
764
|
+
)
|
|
765
|
+
data = source.read_at(entry.data_offset, entry.size)
|
|
766
|
+
verify_file(manifest, path, data)
|
|
767
|
+
return manifest, data
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def verify_file(manifest: Manifest, path: str, data: ByteSource | bytes) -> None:
|
|
771
|
+
"""§6.2 file checks for one retrieved file against a verified manifest.
|
|
772
|
+
|
|
773
|
+
Raises:
|
|
774
|
+
Invalid: the path is not listed, or its size or hash differs.
|
|
775
|
+
"""
|
|
776
|
+
entry = manifest.files.get(path)
|
|
777
|
+
if entry is None:
|
|
778
|
+
raise Invalid(
|
|
779
|
+
Issue(
|
|
780
|
+
"file_not_listed",
|
|
781
|
+
"requested path must be a manifest files key",
|
|
782
|
+
section="6.2",
|
|
783
|
+
file=path,
|
|
784
|
+
)
|
|
785
|
+
)
|
|
786
|
+
source = as_source(data)
|
|
787
|
+
issues = IssueCollector()
|
|
788
|
+
if source.size != entry.bytes:
|
|
789
|
+
issues.add(
|
|
790
|
+
"file_size_mismatch",
|
|
791
|
+
"byte count must equal the manifest bytes value",
|
|
792
|
+
section="6.2",
|
|
793
|
+
file=path,
|
|
794
|
+
observed=f"{source.size} != {entry.bytes}",
|
|
795
|
+
)
|
|
796
|
+
else:
|
|
797
|
+
digest = _FileDigest(crc=False)
|
|
798
|
+
for chunk in iter_chunks(source):
|
|
799
|
+
digest.update(chunk)
|
|
800
|
+
_compare_digest(path, entry, source.size, digest, issues)
|
|
801
|
+
issues.raise_if_any()
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
# --------------------------------------------------------------------------- writing
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def canonical_entries(
|
|
808
|
+
manifest_bytes: bytes,
|
|
809
|
+
signature: bytes,
|
|
810
|
+
files: Mapping[str, ByteSource | bytes],
|
|
811
|
+
) -> list[tuple[str, ByteSource]]:
|
|
812
|
+
"""Entries in §2.2 order: manifest, signature, then listed paths by ASCII bytes."""
|
|
813
|
+
ordered: list[tuple[str, ByteSource]] = [
|
|
814
|
+
(MANIFEST_PATH, MemorySource(manifest_bytes)),
|
|
815
|
+
(SIGNATURE_PATH, MemorySource(signature)),
|
|
816
|
+
]
|
|
817
|
+
ordered.extend((path, as_source(files[path])) for path in sorted(files, key=path_sort_key))
|
|
818
|
+
return ordered
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def write_bundle_zip(
|
|
822
|
+
sink: SupportsWrite,
|
|
823
|
+
manifest_bytes: bytes,
|
|
824
|
+
signature: bytes,
|
|
825
|
+
files: Mapping[str, ByteSource | bytes],
|
|
826
|
+
) -> int:
|
|
827
|
+
"""Write the canonical ZIP (§2.2) to ``sink`` (an object with ``write``); return its size.
|
|
828
|
+
|
|
829
|
+
``files`` must contain exactly the manifest's listed paths. Verify the
|
|
830
|
+
bundle first; this function only serializes.
|
|
831
|
+
"""
|
|
832
|
+
return write_canonical_zip(sink, canonical_entries(manifest_bytes, signature, files))
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def extract_verified(
|
|
836
|
+
bundle: ByteSource | bytes | str | os.PathLike[str],
|
|
837
|
+
destination: str | os.PathLike[str],
|
|
838
|
+
*,
|
|
839
|
+
expected_id: str | None = None,
|
|
840
|
+
limits: VerificationLimits = DEFAULT_LIMITS,
|
|
841
|
+
) -> VerificationResult:
|
|
842
|
+
"""Verify a ZIP completely, then write its entries under ``destination``.
|
|
843
|
+
|
|
844
|
+
Files are written only if verification succeeds, only inside
|
|
845
|
+
``destination`` (§6.4), with exclusive creation (existing files are never
|
|
846
|
+
overwritten). Directories are created as needed. Returns the verification
|
|
847
|
+
result; on failure nothing is written.
|
|
848
|
+
"""
|
|
849
|
+
result = verify_zip(bundle, expected_id=expected_id, limits=limits)
|
|
850
|
+
if not result.ok:
|
|
851
|
+
return result
|
|
852
|
+
root = Path(destination).resolve()
|
|
853
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
854
|
+
opened: FileSource | None = None
|
|
855
|
+
if isinstance(bundle, str | os.PathLike):
|
|
856
|
+
opened = FileSource(bundle)
|
|
857
|
+
source: ByteSource = opened
|
|
858
|
+
else:
|
|
859
|
+
source = as_source(bundle)
|
|
860
|
+
try:
|
|
861
|
+
entries = parse_zip_layout(source)
|
|
862
|
+
for entry in entries:
|
|
863
|
+
target = (root / entry.path).resolve()
|
|
864
|
+
if root != target and root not in target.parents:
|
|
865
|
+
raise ValueError(f"entry escapes destination: {entry.path}")
|
|
866
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
867
|
+
digest = hashlib.sha256()
|
|
868
|
+
with open(target, "xb") as handle:
|
|
869
|
+
for chunk in iter_chunks(source, entry.data_offset, entry.size):
|
|
870
|
+
digest.update(chunk)
|
|
871
|
+
handle.write(chunk)
|
|
872
|
+
listed = result.manifest.files.get(entry.path) if result.manifest else None
|
|
873
|
+
if listed is not None and digest.hexdigest() != listed.sha256:
|
|
874
|
+
raise OSError(f"{entry.path} changed after verification")
|
|
875
|
+
finally:
|
|
876
|
+
if opened is not None:
|
|
877
|
+
opened.close()
|
|
878
|
+
return result
|