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.
Files changed (80) hide show
  1. pysci/__init__.py +77 -0
  2. pysci/__main__.py +8 -0
  3. pysci/_cli/__init__.py +5 -0
  4. pysci/_cli/checks.py +173 -0
  5. pysci/_cli/clients/__init__.py +0 -0
  6. pysci/_cli/clients/archive.py +456 -0
  7. pysci/_cli/clients/credentials.py +54 -0
  8. pysci/_cli/clients/github.py +734 -0
  9. pysci/_cli/clients/hosting.py +238 -0
  10. pysci/_cli/clients/http.py +481 -0
  11. pysci/_cli/clients/huggingface.py +492 -0
  12. pysci/_cli/clients/index.py +435 -0
  13. pysci/_cli/clients/tsa.py +223 -0
  14. pysci/_cli/commands/__init__.py +44 -0
  15. pysci/_cli/commands/check.py +115 -0
  16. pysci/_cli/commands/claim.py +179 -0
  17. pysci/_cli/commands/common.py +91 -0
  18. pysci/_cli/commands/folders.py +83 -0
  19. pysci/_cli/commands/index.py +51 -0
  20. pysci/_cli/commands/keygen.py +58 -0
  21. pysci/_cli/commands/license.py +75 -0
  22. pysci/_cli/commands/protocol.py +72 -0
  23. pysci/_cli/commands/publish.py +282 -0
  24. pysci/_cli/commands/pull.py +337 -0
  25. pysci/_cli/commands/read.py +364 -0
  26. pysci/_cli/commands/revoke.py +207 -0
  27. pysci/_cli/commands/search.py +400 -0
  28. pysci/_cli/commands/sign.py +284 -0
  29. pysci/_cli/commands/submit.py +172 -0
  30. pysci/_cli/commands/timestamp.py +196 -0
  31. pysci/_cli/commands/verify.py +163 -0
  32. pysci/_cli/config.py +233 -0
  33. pysci/_cli/context.py +79 -0
  34. pysci/_cli/destinations.py +163 -0
  35. pysci/_cli/errors.py +168 -0
  36. pysci/_cli/main.py +229 -0
  37. pysci/_cli/output.py +80 -0
  38. pysci/_cli/retrieval.py +51 -0
  39. pysci/_cli/spec.py +113 -0
  40. pysci/_cli/storage/__init__.py +0 -0
  41. pysci/_cli/storage/catalog.py +371 -0
  42. pysci/_cli/storage/files.py +252 -0
  43. pysci/_cli/storage/inputs.py +205 -0
  44. pysci/_cli/storage/keys.py +132 -0
  45. pysci/_cli/storage/local_publish.py +88 -0
  46. pysci/accounts.py +135 -0
  47. pysci/baseline.py +142 -0
  48. pysci/bundle.py +878 -0
  49. pysci/citations.py +233 -0
  50. pysci/data/freetsa-cacert.pem +45 -0
  51. pysci/data/sci-baseline-LICENSE.txt +457 -0
  52. pysci/data/sci-protocol.md +613 -0
  53. pysci/der.py +146 -0
  54. pysci/discovery.py +461 -0
  55. pysci/draft.py +374 -0
  56. pysci/ed25519.py +153 -0
  57. pysci/errors.py +204 -0
  58. pysci/ids.py +61 -0
  59. pysci/jsonstrict.py +225 -0
  60. pysci/keys.py +247 -0
  61. pysci/manifest.py +302 -0
  62. pysci/metadata.py +406 -0
  63. pysci/paths.py +136 -0
  64. pysci/png.py +217 -0
  65. pysci/py.typed +0 -0
  66. pysci/records.py +384 -0
  67. pysci/rfc3339.py +66 -0
  68. pysci/schemas.py +75 -0
  69. pysci/source.py +166 -0
  70. pysci/specification.py +58 -0
  71. pysci/submission.py +225 -0
  72. pysci/templates.py +129 -0
  73. pysci/text.py +226 -0
  74. pysci/timestamps.py +867 -0
  75. pysci/zipformat.py +400 -0
  76. sci_protocol-0.2.0.dist-info/METADATA +112 -0
  77. sci_protocol-0.2.0.dist-info/RECORD +80 -0
  78. sci_protocol-0.2.0.dist-info/WHEEL +4 -0
  79. sci_protocol-0.2.0.dist-info/entry_points.txt +2 -0
  80. 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