chtypes 2.0.0.dev12__tar.gz → 2.0.0.dev13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/CHANGELOG.md +1 -0
  2. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/PKG-INFO +1 -1
  3. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/pyproject.toml +1 -1
  4. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_ensure.py +36 -3
  5. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_oci.py +41 -0
  6. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_preseed_scan.py +120 -0
  7. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/uv.lock +1 -1
  8. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/.gitignore +0 -0
  9. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/LICENSE +0 -0
  10. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/README.md +0 -0
  11. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/__init__.py +0 -0
  12. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/__main__.py +0 -0
  13. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/__init__.py +0 -0
  14. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_decls.py +0 -0
  15. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_errmap.py +0 -0
  16. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_errors.py +0 -0
  17. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_loader.py +0 -0
  18. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_vocab.py +0 -0
  19. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_advisory.py +0 -0
  20. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_codes.py +0 -0
  21. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_decode.py +0 -0
  22. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ed25519.py +0 -0
  23. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_guard.py +0 -0
  24. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_input.py +0 -0
  25. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/__init__.py +0 -0
  26. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_channel.py +0 -0
  27. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_constants.py +0 -0
  28. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_dsse.py +0 -0
  29. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_errors.py +0 -0
  30. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_faults.py +0 -0
  31. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_goldens.py +0 -0
  32. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_hold.py +0 -0
  33. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_http.py +0 -0
  34. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_layout.py +0 -0
  35. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_lock.py +0 -0
  36. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_prune.py +0 -0
  37. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_referrers.py +0 -0
  38. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_resolve.py +0 -0
  39. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_retired.py +0 -0
  40. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_revocation.py +0 -0
  41. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_unpack.py +0 -0
  42. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_setup.py +0 -0
  43. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/errors.py +0 -0
  44. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/library.py +0 -0
  45. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/py.typed +0 -0
  46. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/registry.py +0 -0
  47. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/results.py +0 -0
  48. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/__init__.py +0 -0
  49. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/_stub_tree.py +0 -0
  50. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/conftest.py +0 -0
  51. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_conformance.py +0 -0
  52. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_dev_fingerprint.py +0 -0
  53. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_goldens_runner_stub.py +0 -0
  54. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_loader.py +0 -0
  55. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_library.py +0 -0
  56. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_open.py +0 -0
  57. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_registry.py +0 -0
  58. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_server.py +0 -0
  59. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_setup_cases.py +0 -0
  60. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_reader_rules.py +0 -0
  61. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_registry_fetch.py +0 -0
  62. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_registry_flight.py +0 -0
  63. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/__init__.py +0 -0
  64. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/test_decode.py +0 -0
  65. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/test_input.py +0 -0
  66. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/test_trusted_keys.py +0 -0
  67. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/test_vocab.py +0 -0
  68. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/__init__.py +0 -0
  69. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/conftest.py +0 -0
  70. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/test_devchannel.py +0 -0
  71. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/test_main.py +0 -0
  72. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/test_resolve_prune.py +0 -0
  73. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/test_revocation_cli.py +0 -0
  74. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/conftest.py +0 -0
  75. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/goldens_v1/__init__.py +0 -0
  76. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/goldens_v1/data/stub-goldens.json +0 -0
  77. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/goldens_v1/test_runner.py +0 -0
  78. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/__init__.py +0 -0
  79. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/_bundle_support.py +0 -0
  80. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/_registry_support.py +0 -0
  81. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/_sign_support.py +0 -0
  82. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/conftest.py +0 -0
  83. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_cache_roots.py +0 -0
  84. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_cache_strict.py +0 -0
  85. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_conformance.py +0 -0
  86. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_devchannel.py +0 -0
  87. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_dsse.py +0 -0
  88. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_ensure.py +0 -0
  89. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_goldens.py +0 -0
  90. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_http.py +0 -0
  91. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_layout.py +0 -0
  92. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_lock.py +0 -0
  93. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_oci.py +0 -0
  94. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_prodv2_smoke.py +0 -0
  95. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_prodv2channel.py +0 -0
  96. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_retired.py +0 -0
  97. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_revocation.py +0 -0
  98. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_revocation_statement.py +0 -0
  99. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_seam_gate.py +0 -0
  100. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_unpack.py +0 -0
  101. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_abi_identity.py +0 -0
  102. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_cache_roots_api.py +0 -0
  103. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_devchannel_registry.py +0 -0
  104. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_ed25519.py +0 -0
  105. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_examples_tour.py +0 -0
  106. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_fetch_options.py +0 -0
  107. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_support_v2_gen.py +0 -0
@@ -91,6 +91,7 @@ The four bindings in this repository are released together and give one answer,
91
91
 
92
92
  ### Fixed
93
93
 
94
+ - Installing a pre-seeded cache entry from local blobs now checks the layer blob against the manifest's layer descriptor (size and sha256) before it decompresses it, and the manifest blob against its digest and its `index.json` descriptor's size before it parses it, as a downloaded blob already was and as the other three bindings already did. The install had checked only that the signed statement names the layer's digest, then unpacked the file stored under that name without hashing it, so a file replaced under a signed layer's name, holding the same library bytes plus other members, was installed with those members. Such an entry is now not installed, and the lookup is a miss. The installed library itself was always re-hashed against the signed predicate.
94
95
  - An offline lookup no longer reads every cached blob once per pre-seeded entry (#690). For each `index.json` entry with no record, the lookup read every blob of the layout, layers included, to find its signature, so entries it could not install (a signature not present, or one no trusted key verifies) cost entries × cache bytes on every lookup: about 4.9 s for 27 such entries over 1.27 GB (`measured`, darwin-arm64). The signature referrers are now read once per lookup, and never a layer, because a blob larger than `limits.manifest_bytes` cannot be a manifest. Installed entries were already skipped. The build that is installed is verified exactly as before: its manifest against its descriptor, its whole layer against its digest, and its signature, platform, request and fingerprint. A unit test counts the blob reads of one lookup that installs nothing and holds them to at most two per blob and five per entry, with no layer read, at 2 and at 20 entries alike.
95
96
  - Docs: `docs/guides/settings.md` names the ABI v2 admission time budget `chtypes_default_eval_cpu_nanos`, the evaluating thread's CPU time. Its examples used the 1.x name `chtypes_default_eval_wall_nanos`, which v2 refuses with `CHS_INVALID_ARGUMENT`.
96
97
  - A response body cut off part way (shorter than its Content-Length, a reset, a TLS close) is retried like a 5xx and, once the retry table is spent, is `CHTYPES_SOURCE_UNREACHABLE`; only an oversize body is `CHTYPES_ARTIFACT_CORRUPT`, and it is never retried (#641). Python took a body that ended before its Content-Length as the whole body and failed its digest check as corrupt without a retry; now a short body, or `http.client.IncompleteRead`, is retried and ends as unreachable. The rule is `docs/guides/fetch-v1.md` section 7, and the new `body-cut-off-then-ok` and `body-cut-off-exhausted` conformance cases hold every binding to it.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: chtypes
3
- Version: 2.0.0.dev12
3
+ Version: 2.0.0.dev13
4
4
  Summary: Python binding for the chtypes artifacts: ClickHouse's own type system, per version, behind the frozen chs_* C ABI
5
5
  Project-URL: Homepage, https://github.com/wave-rf/chtypes
6
6
  Project-URL: Repository, https://github.com/wave-rf/chtypes
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chtypes"
3
- version = "2.0.0.dev12"
3
+ version = "2.0.0.dev13"
4
4
  description = "Python binding for the chtypes artifacts: ClickHouse's own type system, per version, behind the frozen chs_* C ABI"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -75,6 +75,7 @@ from chtypes._ocifetch._layout import (
75
75
  from chtypes._ocifetch._lock import Lock, LockPin, load_lock, new_lock, save_lock
76
76
  from chtypes._ocifetch._oci import (
77
77
  Descriptor,
78
+ copy_blob_verified,
78
79
  fetch_blob_bytes,
79
80
  fetch_blob_to_path,
80
81
  fetch_manifest_by_digest,
@@ -86,6 +87,8 @@ from chtypes._ocifetch._oci import (
86
87
  resolve_platform_manifest,
87
88
  spelling_components,
88
89
  validate_spelling,
90
+ verify_body_matches_digest,
91
+ verify_json_descriptor,
89
92
  version_within_request,
90
93
  )
91
94
  from chtypes._ocifetch._referrers import ReferrerListing, discover_referrers, read_listing
@@ -1346,6 +1349,7 @@ def _verify_and_install_from_local_blobs(
1346
1349
  trusted_keys: tuple[TrustedKey, ...],
1347
1350
  visible_only: bool = False,
1348
1351
  referrers: dict[str, list[tuple[str, dict]]] | None = None,
1352
+ manifest_size: int | None = None,
1349
1353
  ) -> VerifiedRecord | None:
1350
1354
  """docs/guides/fetch-v1.md §1/§6: "A pre-seeded layout has entries in
1351
1355
  `index.json` with no corresponding `unpacked/` directory yet; the first
@@ -1357,7 +1361,14 @@ def _verify_and_install_from_local_blobs(
1357
1361
  `source_root/blobs/sha256/*`, never the network. Returns `None` — never
1358
1362
  raises — for a missing, unreadable or untrusted entry, the same
1359
1363
  tolerant shape `_find_verified_signature` uses for a network referrer:
1360
- a bad pre-seed is "nothing usable here," not a crash."""
1364
+ a bad pre-seed is "nothing usable here," not a crash.
1365
+
1366
+ The manifest blob is checked against `manifest_digest` (and against
1367
+ `manifest_size`, the index descriptor's size, when the caller has one)
1368
+ before it is parsed, and the layer blob against the manifest's layer
1369
+ descriptor (size and sha256) before it is decompressed: the same
1370
+ checks a download gets (§7.3), so no byte the signature does not cover
1371
+ reaches the install."""
1361
1372
  blobs_dir = source_root / "blobs" / "sha256"
1362
1373
  if not blobs_dir.is_dir():
1363
1374
  return None
@@ -1366,7 +1377,15 @@ def _verify_and_install_from_local_blobs(
1366
1377
  if not manifest_path.is_file():
1367
1378
  return None
1368
1379
  try:
1369
- manifest_doc = json.loads(manifest_path.read_bytes())
1380
+ manifest_bytes = manifest_path.read_bytes()
1381
+ verify_body_matches_digest(manifest_bytes, manifest_digest, what="pre-seeded manifest")
1382
+ if manifest_size is not None:
1383
+ verify_json_descriptor(
1384
+ manifest_bytes,
1385
+ Descriptor(media_type="", digest=manifest_digest, size=manifest_size),
1386
+ what="pre-seeded manifest",
1387
+ )
1388
+ manifest_doc = json.loads(manifest_bytes)
1370
1389
  layer_desc = manifest_layer_descriptor(manifest_doc)
1371
1390
  except (ValueError, OSError, FetchError):
1372
1391
  return None
@@ -1415,9 +1434,18 @@ def _verify_and_install_from_local_blobs(
1415
1434
  scratch = _scratch_root(cache_write_root)
1416
1435
  tmp_dir = tempfile.mkdtemp(dir=str(scratch))
1417
1436
  try:
1437
+ # Size and sha256 against the layer descriptor BEFORE decompressing
1438
+ # (§7.3), on a copy in scratch, which is then the file unpacked.
1439
+ verified_layer = os.path.join(tmp_dir, "layer")
1440
+ copy_blob_verified(
1441
+ str(layer_path),
1442
+ layer_desc,
1443
+ verified_layer,
1444
+ what=f"pre-seeded layer {layer_desc.digest}",
1445
+ )
1418
1446
  unpacked_dir = os.path.join(tmp_dir, "unpacked")
1419
1447
  unpack_tar_zst(
1420
- str(layer_path),
1448
+ verified_layer,
1421
1449
  unpacked_dir,
1422
1450
  library_name=predicate["library"],
1423
1451
  library_sha256=predicate["library_sha256"],
@@ -1469,11 +1497,15 @@ def _verify_preseeded_entries(roots: Sequence[Path], trusted_keys: tuple[Trusted
1469
1497
  candidates: list[str] = []
1470
1498
  candidate_layers: set[str] = set()
1471
1499
  manifests_read: dict[str, bytes] = {}
1500
+ sizes: dict[str, int] = {}
1472
1501
  for m in index_doc.get("manifests") or []:
1473
1502
  digest = m.get("digest")
1474
1503
  if not digest or digest in already_verified:
1475
1504
  continue
1476
1505
  candidates.append(digest)
1506
+ size = m.get("size")
1507
+ if isinstance(size, int) and not isinstance(size, bool):
1508
+ sizes[digest] = size
1477
1509
  try:
1478
1510
  manifest_hex = parse_digest(digest)
1479
1511
  manifest_bytes = (blobs_dir / manifest_hex).read_bytes()
@@ -1495,6 +1527,7 @@ def _verify_preseeded_entries(roots: Sequence[Path], trusted_keys: tuple[Trusted
1495
1527
  trusted_keys=trusted_keys,
1496
1528
  visible_only=True,
1497
1529
  referrers=referrers,
1530
+ manifest_size=sizes.get(digest),
1498
1531
  )
1499
1532
  if record is not None:
1500
1533
  already_verified.add(digest)
@@ -26,6 +26,7 @@ from chtypes._ocifetch._http import (
26
26
 
27
27
  __all__ = [
28
28
  "Descriptor",
29
+ "copy_blob_verified",
29
30
  "fetch_blob_bytes",
30
31
  "fetch_blob_to_path",
31
32
  "fetch_manifest_by_digest",
@@ -143,6 +144,46 @@ def verify_body_matches_digest(body: bytes, digest: str, *, what: str) -> None:
143
144
  )
144
145
 
145
146
 
147
+ def copy_blob_verified(src_path: str, descriptor: Descriptor, dest_path: str, *, what: str) -> None:
148
+ """Copy a LOCAL blob file (a pre-seeded layout's `blobs/sha256/<hex>`)
149
+ to `dest_path`, streaming, and check the copy's size and sha256 against
150
+ `descriptor` BEFORE the caller does anything else with it — the local
151
+ counterpart of `fetch_blob_to_path`, with the same §7.3 rule ("check
152
+ size and sha256 before decompressing"). The caller unpacks the copy it
153
+ verified, never the source file, so the bytes checked are the bytes
154
+ used. Reads at most `descriptor.size + 1` bytes; removes `dest_path` on
155
+ any failure."""
156
+ expected = parse_digest(descriptor.digest)
157
+ hasher = hashlib.sha256()
158
+ size = 0
159
+ try:
160
+ with open(src_path, "rb") as src, open(dest_path, "wb") as dst:
161
+ while size <= descriptor.size:
162
+ chunk = src.read(min(1 << 20, descriptor.size + 1 - size))
163
+ if not chunk:
164
+ break
165
+ hasher.update(chunk)
166
+ dst.write(chunk)
167
+ size += len(chunk)
168
+ if size > descriptor.size:
169
+ raise ArtifactCorruptError(
170
+ f"{what}: larger than the descriptor's size {descriptor.size}"
171
+ )
172
+ if size != descriptor.size:
173
+ raise ArtifactCorruptError(
174
+ f"{what}: size {size} disagreed with the descriptor's {descriptor.size}"
175
+ )
176
+ actual = hasher.hexdigest()
177
+ if actual != expected:
178
+ raise ArtifactCorruptError(f"{what}: sha256 {actual} disagreed with {expected}")
179
+ except BaseException:
180
+ try:
181
+ os.unlink(dest_path)
182
+ except OSError:
183
+ pass
184
+ raise
185
+
186
+
146
187
  def resolve_platform_manifest(index_doc: dict, platform_key: str) -> Descriptor:
147
188
  """Pick the one manifest descriptor in an OCI image index matching
148
189
  `platform_key` (e.g. "linux-arm64"), refusing a duplicate or absent
@@ -8,8 +8,10 @@ The verdicts come from the blob reads the lookup itself made (every
8
8
  from __future__ import annotations
9
9
 
10
10
  import hashlib
11
+ import io
11
12
  import json
12
13
  import shutil
14
+ import tarfile
13
15
  from collections import Counter
14
16
  from collections.abc import Callable
15
17
  from pathlib import Path
@@ -159,3 +161,121 @@ def test_preseeded_install_still_verifies_the_layer(tmp_path: Path) -> None:
159
161
  layer.write_bytes(bytes(b))
160
162
  assert resolve_installed(Request("26.1"), "linux-arm64", _options(cache, "test-key")) is None
161
163
  assert not (cache / "unpacked" / "sha256" / digests[0].removeprefix("sha256:")).exists()
164
+
165
+
166
+ def _tamper_layer(original: bytes, case: str) -> bytes:
167
+ """A different valid tar.zst carrying the signed library member's bytes
168
+ unchanged. "extra-member": every member of `original` plus `planted.txt`.
169
+ "manifest-json": the tarball's `manifest.json` altered — the signed
170
+ fixture layer has none (its members are `docs/` and the library), so its
171
+ altered form is a `manifest.json` the signed layer does not carry, beside
172
+ the same library bytes. Neither is covered by any signature."""
173
+ try:
174
+ from compression import zstd # Python 3.14+
175
+ except ImportError: # Python 3.11-3.13
176
+ from backports import zstd # type: ignore[no-redef]
177
+
178
+ def add(dst: tarfile.TarFile, name: str, body: bytes) -> None:
179
+ info = tarfile.TarInfo(name)
180
+ info.size = len(body)
181
+ dst.addfile(info, io.BytesIO(body))
182
+
183
+ src = tarfile.open(fileobj=io.BytesIO(zstd.decompress(original)))
184
+ out = io.BytesIO()
185
+ with tarfile.open(fileobj=out, mode="w", format=tarfile.PAX_FORMAT) as dst:
186
+ for member in src.getmembers():
187
+ if member.name == "manifest.json":
188
+ continue
189
+ dst.addfile(member, src.extractfile(member) if member.isreg() else None)
190
+ if case == "extra-member":
191
+ add(dst, "planted.txt", b"bytes no signature covers\n")
192
+ else:
193
+ add(dst, "manifest.json", b'{"planted.txt": "bytes no signature covers"}\n')
194
+ return zstd.compress(out.getvalue())
195
+
196
+
197
+ def _unpack_spy(monkeypatch: pytest.MonkeyPatch) -> list[str]:
198
+ """Every layer file the local install handed to `unpack_tar_zst`."""
199
+ import chtypes._ocifetch._ensure as ensure_mod
200
+
201
+ calls: list[str] = []
202
+ real = ensure_mod.unpack_tar_zst
203
+
204
+ def spy(path: str, *args: object, **kwargs: object) -> None:
205
+ calls.append(path)
206
+ real(path, *args, **kwargs) # type: ignore[arg-type]
207
+
208
+ monkeypatch.setattr(ensure_mod, "unpack_tar_zst", spy)
209
+ return calls
210
+
211
+
212
+ def _assert_nothing_installed(cache: Path, manifest: str) -> None:
213
+ leaked = [*cache.rglob("planted.txt"), *(cache / "unpacked").rglob("manifest.json")]
214
+ assert leaked == [], f"bytes outside the signature were installed: {leaked}"
215
+ assert not (cache / "unpacked" / "sha256" / manifest.removeprefix("sha256:")).exists()
216
+
217
+
218
+ @pytest.mark.parametrize("case", ["extra-member", "manifest-json"])
219
+ def test_preseeded_layer_swapped_under_its_signed_name_installs_nothing(
220
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str
221
+ ) -> None:
222
+ """The layer blob FILE under its signed digest name is replaced by a
223
+ different valid tar.zst carrying the same library bytes. The library
224
+ re-hash alone passes on those bytes; the layer descriptor check (size
225
+ and sha256) refuses them BEFORE any unpack. The lookup installs nothing
226
+ and is a miss."""
227
+ cache, digests = _seed_clones(tmp_path, 1)
228
+ layer = cache / "blobs" / "sha256" / BUILD_LAYER_HEX
229
+ layer.write_bytes(_tamper_layer(layer.read_bytes(), case))
230
+ assert hashlib.sha256(layer.read_bytes()).hexdigest() != BUILD_LAYER_HEX
231
+ unpacked = _unpack_spy(monkeypatch)
232
+ result = resolve_installed(Request("26.1"), "linux-arm64", _options(cache, "test-key"))
233
+ _assert_nothing_installed(cache, digests[0])
234
+ assert unpacked == [], f"{case}: the unverified layer reached unpack"
235
+ assert result is None
236
+
237
+
238
+ def test_preseeded_manifest_swapped_under_its_digest_name_installs_nothing(
239
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
240
+ ) -> None:
241
+ """The manifest blob FILE under its index digest is replaced by other
242
+ bytes naming the same signed layer: the manifest no longer hashes to the
243
+ digest the signature names, and the lookup installs nothing."""
244
+ cache, digests = _seed_clones(tmp_path, 1)
245
+ path = cache / "blobs" / "sha256" / digests[0].removeprefix("sha256:")
246
+ manifest = json.loads(path.read_bytes())
247
+ manifest["annotations"] = {"test.swapped": "1"}
248
+ path.write_bytes(json.dumps(manifest).encode())
249
+ unpacked = _unpack_spy(monkeypatch)
250
+ assert resolve_installed(Request("26.1"), "linux-arm64", _options(cache, "test-key")) is None
251
+ assert unpacked == []
252
+ _assert_nothing_installed(cache, digests[0])
253
+
254
+
255
+ def test_preseeded_manifest_size_disagreeing_with_index_installs_nothing(
256
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
257
+ ) -> None:
258
+ """The index descriptor's size disagrees with the manifest blob's: the
259
+ manifest is checked against its whole descriptor, as a download is."""
260
+ cache, digests = _seed_clones(tmp_path, 1)
261
+ index = json.loads((cache / "index.json").read_text())
262
+ index["manifests"][0]["size"] += 1
263
+ (cache / "index.json").write_text(json.dumps(index))
264
+ unpacked = _unpack_spy(monkeypatch)
265
+ assert resolve_installed(Request("26.1"), "linux-arm64", _options(cache, "test-key")) is None
266
+ assert unpacked == []
267
+ _assert_nothing_installed(cache, digests[0])
268
+
269
+
270
+ def test_preseeded_untouched_layout_still_installs(
271
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
272
+ ) -> None:
273
+ """The control for the cases above: the same layout, untouched, reaches
274
+ unpack once, installs and answers."""
275
+ cache, digests = _seed_clones(tmp_path, 1)
276
+ unpacked = _unpack_spy(monkeypatch)
277
+ assert (
278
+ resolve_installed(Request("26.1"), "linux-arm64", _options(cache, "test-key")) is not None
279
+ )
280
+ assert len(unpacked) == 1
281
+ assert (cache / "unpacked" / "sha256" / digests[0].removeprefix("sha256:")).is_dir()
@@ -74,7 +74,7 @@ wheels = [
74
74
 
75
75
  [[package]]
76
76
  name = "chtypes"
77
- version = "2.0.0.dev12"
77
+ version = "2.0.0.dev13"
78
78
  source = { editable = "." }
79
79
  dependencies = [
80
80
  { name = "backports-zstd", marker = "python_full_version < '3.14'" },
File without changes
File without changes
File without changes