chtypes 2.0.0.dev12__tar.gz → 2.0.0.dev14__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 (108) hide show
  1. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/CHANGELOG.md +3 -0
  2. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/PKG-INFO +1 -1
  3. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/pyproject.toml +1 -1
  4. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_decode.py +3 -0
  5. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_ensure.py +36 -3
  6. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_oci.py +41 -0
  7. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/results.py +17 -1
  8. chtypes-2.0.0.dev14/tests/abi2/test_null_input.py +153 -0
  9. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_preseed_scan.py +120 -0
  10. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/uv.lock +1 -1
  11. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/.gitignore +0 -0
  12. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/LICENSE +0 -0
  13. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/README.md +0 -0
  14. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/__init__.py +0 -0
  15. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/__main__.py +0 -0
  16. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_abi2/__init__.py +0 -0
  17. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_abi2/_decls.py +0 -0
  18. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_abi2/_errmap.py +0 -0
  19. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_abi2/_errors.py +0 -0
  20. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_abi2/_loader.py +0 -0
  21. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_abi2/_vocab.py +0 -0
  22. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_advisory.py +0 -0
  23. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_codes.py +0 -0
  24. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ed25519.py +0 -0
  25. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_guard.py +0 -0
  26. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_input.py +0 -0
  27. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/__init__.py +0 -0
  28. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_channel.py +0 -0
  29. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_constants.py +0 -0
  30. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_dsse.py +0 -0
  31. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_errors.py +0 -0
  32. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_faults.py +0 -0
  33. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_goldens.py +0 -0
  34. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_hold.py +0 -0
  35. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_http.py +0 -0
  36. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_layout.py +0 -0
  37. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_lock.py +0 -0
  38. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_prune.py +0 -0
  39. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_referrers.py +0 -0
  40. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_resolve.py +0 -0
  41. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_retired.py +0 -0
  42. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_revocation.py +0 -0
  43. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_ocifetch/_unpack.py +0 -0
  44. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/_setup.py +0 -0
  45. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/errors.py +0 -0
  46. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/library.py +0 -0
  47. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/py.typed +0 -0
  48. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/src/chtypes/registry.py +0 -0
  49. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/__init__.py +0 -0
  50. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/_stub_tree.py +0 -0
  51. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/conftest.py +0 -0
  52. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_conformance.py +0 -0
  53. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_dev_fingerprint.py +0 -0
  54. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_goldens_runner_stub.py +0 -0
  55. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_loader.py +0 -0
  56. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_public_library.py +0 -0
  57. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_public_open.py +0 -0
  58. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_public_registry.py +0 -0
  59. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_public_server.py +0 -0
  60. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_public_setup_cases.py +0 -0
  61. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_reader_rules.py +0 -0
  62. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_registry_fetch.py +0 -0
  63. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/abi2/test_registry_flight.py +0 -0
  64. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/api/__init__.py +0 -0
  65. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/api/test_decode.py +0 -0
  66. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/api/test_input.py +0 -0
  67. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/api/test_trusted_keys.py +0 -0
  68. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/api/test_vocab.py +0 -0
  69. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/cli/__init__.py +0 -0
  70. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/cli/conftest.py +0 -0
  71. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/cli/test_devchannel.py +0 -0
  72. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/cli/test_main.py +0 -0
  73. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/cli/test_resolve_prune.py +0 -0
  74. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/cli/test_revocation_cli.py +0 -0
  75. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/conftest.py +0 -0
  76. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/goldens_v1/__init__.py +0 -0
  77. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/goldens_v1/data/stub-goldens.json +0 -0
  78. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/goldens_v1/test_runner.py +0 -0
  79. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/__init__.py +0 -0
  80. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/_bundle_support.py +0 -0
  81. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/_registry_support.py +0 -0
  82. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/_sign_support.py +0 -0
  83. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/conftest.py +0 -0
  84. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_cache_roots.py +0 -0
  85. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_cache_strict.py +0 -0
  86. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_conformance.py +0 -0
  87. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_devchannel.py +0 -0
  88. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_dsse.py +0 -0
  89. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_ensure.py +0 -0
  90. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_goldens.py +0 -0
  91. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_http.py +0 -0
  92. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_layout.py +0 -0
  93. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_lock.py +0 -0
  94. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_oci.py +0 -0
  95. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_prodv2_smoke.py +0 -0
  96. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_prodv2channel.py +0 -0
  97. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_retired.py +0 -0
  98. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_revocation.py +0 -0
  99. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_revocation_statement.py +0 -0
  100. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_seam_gate.py +0 -0
  101. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/ocifetch/test_unpack.py +0 -0
  102. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/test_abi_identity.py +0 -0
  103. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/test_cache_roots_api.py +0 -0
  104. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/test_devchannel_registry.py +0 -0
  105. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/test_ed25519.py +0 -0
  106. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/test_examples_tour.py +0 -0
  107. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/test_fetch_options.py +0 -0
  108. {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev14}/tests/test_support_v2_gen.py +0 -0
@@ -8,6 +8,8 @@ The four bindings in this repository are released together and give one answer,
8
8
 
9
9
  ### Added
10
10
 
11
+ - `Value.null_input` (ABI v2): whether the input field was the format's NULL literal (a JSON `null`, a TSV `\N`), decoded from `cols[].null_input` in the row and batch documents; false when the document carries none. It differs from `null` where a NULL literal is read into a column that is not Nullable. Decode cells over the ABI v2 stub (variant `null-input`: a NULL literal into a non-Nullable UUID, FixedString, Int32, DateTime, LowCardinality(String) and Enum8 column, in JSONEachRow and TSV) run in all four bindings.
12
+ - `Value.poison` and `Value.dup_dropped` (ABI v2): whether the stored value is one the server cannot read back (the batch is then `accepted_poisoned` and exports nothing), and whether the reader dropped a duplicate key, decoded from `cols[].poison` and `cols[].dup_dropped` in the row and batch documents; false when the document carries none. Decode cells over the ABI v2 stub (variant `value-flags`: a NULL read into a non-Nullable Enum8 in JSONEachRow and TSV, and a repeated top-level JSONEachRow key that arrives after every column has been read, which the reader skips so the first value is stored, with controls holding each member false and absent) run in all four bindings.
11
13
  - Tests: the shared revocation-statement table (`tests/fixtures/revocation-statement/cases.json`) runs against `verify_revocation` and `check_installed`: `superseded_by` must be a string (`""` = no fix), and the first verifying revocation in a listing wins. No behavior changed.
12
14
  - `column_defaults` is covered by unit tests (ABI v2): the exact `insert_options` JSON for a UTF-8 value, a non-UTF-8 value (`value_b64`), an empty value (sent as `""`) and two entries in order, and the decode of a `default_override` cell (`Source.DEFAULT_OVERRIDE`, stored). The batches guide gains a usage section. No behavior changed.
13
15
  - Docs: `docs/guides/batches.md` shows how a `column_defaults` value is read, in the body format's own field encoding (CSV quoting, TSV escapes, a JSON string or number), with a value that doesn't read refused at the call before any body byte.
@@ -91,6 +93,7 @@ The four bindings in this repository are released together and give one answer,
91
93
 
92
94
  ### Fixed
93
95
 
96
+ - 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
97
  - 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
98
  - 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
99
  - 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.dev14
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.dev14"
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"
@@ -322,6 +322,9 @@ def _value(obj: dict[str, Any], where: str) -> Value:
322
322
  source=source,
323
323
  is_stored=Source.is_stored(source),
324
324
  value=_opt_b64(obj, "value_b64", where),
325
+ null_input=_bool(obj, "null_input", where),
326
+ poison=_bool(obj, "poison", where),
327
+ dup_dropped=_bool(obj, "dup_dropped", where),
325
328
  )
326
329
 
327
330
 
@@ -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
@@ -73,7 +73,20 @@ class Value:
73
73
  bytes of a scalar String or FixedString leaf (from `value_b64`), None where
74
74
  the document carries none. `null`: the library's verdict, poison included.
75
75
  `source`: the `value_src` spelling (from `src`); `Source` lists them.
76
- `is_stored`: the description's own fact for `source`.
76
+ `is_stored`: the description's own fact for `source`. `null_input`: whether
77
+ the input field was the format's NULL literal (a JSON null, a TSV `\\N`),
78
+ from `null_input`, False where the document carries none; a fact about the
79
+ input, where `null` is one about the stored value, so the two differ where
80
+ a NULL literal is read into a column that is not Nullable. `poison`: the
81
+ stored value is one the server cannot read back (the batch is then
82
+ accepted_poisoned and exports nothing), from `poison`; `dup_dropped`: the
83
+ reader dropped a duplicate key for this column, from `dup_dropped` (at default
84
+ settings a repeated top-level JSONEachRow key that arrives after every column
85
+ has been read is skipped: the first value is stored, this is True, and a
86
+ `duplicate_key_dropped` transform is reported; a repeat while a column is
87
+ still unread is refused with 117; a repeated key inside a Map value keeps
88
+ both keys). Each is
89
+ False where the document carries none.
77
90
  """
78
91
 
79
92
  column: bytes
@@ -82,6 +95,9 @@ class Value:
82
95
  source: str
83
96
  is_stored: bool
84
97
  value: bytes | None = None
98
+ null_input: bool = False
99
+ poison: bool = False
100
+ dup_dropped: bool = False
85
101
 
86
102
 
87
103
  @dataclass(frozen=True, slots=True)
@@ -0,0 +1,153 @@
1
+ """The null_input decode cells, through the public API, over the generated v2
2
+ stub (variant "null-input").
3
+
4
+ The stub answers scripts/abi-v1/emit/_stubshared.py's NULL_INPUT_DOCS: for a
5
+ JSON null and a TSV `\\N` into each of six non-Nullable types (UUID,
6
+ FixedString(4), Int32, DateTime, LowCardinality(String), Enum8), the entry
7
+ carries `null_input` true; two controls carry it false and absent. Each entry's
8
+ expected value is read off its name (a NULL_INPUT_CONTROL_PREFIX name expects
9
+ False), and the cell count is the generator's NULL_INPUT_CELL_COUNT, so a
10
+ document that lost its cells fails. The other three bindings pin that count as
11
+ 12; `test_null_input_cell_count_is_pinned` fails here first if the generator
12
+ changes it. Without `$CHTYPES_ABI2_STUBS` the stub tests skip loudly by name.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import shutil
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import pytest
22
+
23
+ from chtypes import Format, Value, library
24
+
25
+ from .conftest import REPO_ROOT, stub_path
26
+
27
+ PINNED_CELL_COUNT = 12 # Go, TypeScript and Rust pin the same number
28
+
29
+
30
+ def _shared():
31
+ sys.path.insert(0, str(REPO_ROOT / "scripts" / "abi-v1"))
32
+ from emit import _stubshared
33
+
34
+ return _stubshared
35
+
36
+
37
+ @pytest.fixture
38
+ def null_input_library(
39
+ stubs_dir: Path, stubs_manifest: dict, tmp_path: Path, clean_process, monkeypatch
40
+ ):
41
+ monkeypatch.setenv("CHTYPES_ALLOW_UNVERIFIED_LIBRARY", "1")
42
+ target = tmp_path / "null-input.so"
43
+ shutil.copyfile(stub_path(stubs_dir, stubs_manifest["variants"]["null-input"]), target)
44
+ target.chmod(0o755)
45
+ with pytest.warns(UserWarning, match="UNVERIFIED"):
46
+ return library.open_unverified(target, allow=True)
47
+
48
+
49
+ def _check(where: str, cols: tuple[Value, ...], prefix: bytes) -> int:
50
+ cells = 0
51
+ for c in cols:
52
+ want = not c.column.startswith(prefix)
53
+ cells += want
54
+ assert c.null_input is want, (
55
+ f"{where}: column {c.column!r}: null_input = {c.null_input}, want {want}"
56
+ )
57
+ # A NULL into a non-Nullable Enum8 stores 0, outside the enum: poisoned.
58
+ want_poison = want and c.column.endswith(b".enum8")
59
+ assert c.poison is want_poison, (
60
+ f"{where}: column {c.column!r}: poison = {c.poison}, want {want_poison}"
61
+ )
62
+ return cells
63
+
64
+
65
+ def test_null_input_cell_count_is_pinned() -> None:
66
+ assert _shared().NULL_INPUT_CELL_COUNT == PINNED_CELL_COUNT
67
+
68
+
69
+ def test_stub_null_input(null_input_library) -> None:
70
+ shared = _shared()
71
+ prefix = shared.NULL_INPUT_CONTROL_PREFIX.encode()
72
+ schema = null_input_library.compile_table(b"CREATE TABLE t (x Int32)")
73
+
74
+ row = schema.row(Format.JSON_EACH_ROW, b'{"x":null}')
75
+ assert _check("row", row.columns, prefix) == shared.NULL_INPUT_CELL_COUNT
76
+
77
+ batch = schema.rows(Format.TSV, b"\\N\n")
78
+ cells = sum(_check(f"batch.rows[{i}]", r.columns, prefix) for i, r in enumerate(batch.rows))
79
+ assert cells == shared.NULL_INPUT_CELL_COUNT
80
+
81
+
82
+ # The poison and dup_dropped cells, variant "value-flags"
83
+ # (_stubshared.VALUE_FLAGS_DOCS): a JSON null or TSV \N into a non-Nullable
84
+ # Enum8 (poison, in JSONEachRow and TSV) and one canned entry that carries
85
+ # dup_dropped, plus four controls that hold each member false and absent. A
86
+ # "poison." entry expects poison True and dup_dropped False, a "dup." entry the
87
+ # reverse, a "control." entry both False, so a binding that swaps the members
88
+ # fails. The other three bindings pin the counts as 2 and 1.
89
+ PINNED_POISON_CELLS = 2
90
+ PINNED_DUP_CELLS = 1
91
+
92
+
93
+ @pytest.fixture
94
+ def value_flags_library(
95
+ stubs_dir: Path, stubs_manifest: dict, tmp_path: Path, clean_process, monkeypatch
96
+ ):
97
+ monkeypatch.setenv("CHTYPES_ALLOW_UNVERIFIED_LIBRARY", "1")
98
+ target = tmp_path / "value-flags.so"
99
+ shutil.copyfile(stub_path(stubs_dir, stubs_manifest["variants"]["value-flags"]), target)
100
+ target.chmod(0o755)
101
+ with pytest.warns(UserWarning, match="UNVERIFIED"):
102
+ return library.open_unverified(target, allow=True)
103
+
104
+
105
+ def _check_flags(where: str, cols: tuple[Value, ...], shared) -> tuple[int, int]:
106
+ poison = dup = 0
107
+ for c in cols:
108
+ name = c.column.decode()
109
+ want_p = name.startswith(shared.VALUE_FLAGS_POISON_PREFIX)
110
+ want_d = name.startswith(shared.VALUE_FLAGS_DUP_PREFIX)
111
+ if not (want_p or want_d):
112
+ assert name.startswith(shared.VALUE_FLAGS_CONTROL_PREFIX), (
113
+ f"{where}: unexpected {name!r}"
114
+ )
115
+ poison += want_p
116
+ dup += want_d
117
+ if want_d:
118
+ # The producing input: a repeated top-level JSONEachRow key after
119
+ # every column was read; the first value is stored.
120
+ assert (name, c.text) == ("dup.JSONEachRow.repeated_top_key", b"1"), where
121
+ assert (c.poison, c.dup_dropped) == (want_p, want_d), (
122
+ f"{where}: column {name!r}: poison = {c.poison}, dup_dropped = {c.dup_dropped}, "
123
+ f"want {want_p}, {want_d}"
124
+ )
125
+ return poison, dup
126
+
127
+
128
+ def test_value_flags_cell_counts_are_pinned() -> None:
129
+ shared = _shared()
130
+ assert shared.VALUE_FLAGS_POISON_CELLS == PINNED_POISON_CELLS
131
+ assert shared.VALUE_FLAGS_DUP_CELLS == PINNED_DUP_CELLS
132
+
133
+
134
+ def test_stub_value_flags(value_flags_library) -> None:
135
+ shared = _shared()
136
+ schema = value_flags_library.compile_table(b"CREATE TABLE t (x Int32)")
137
+
138
+ row = schema.row(Format.JSON_EACH_ROW, b'{"a":1,"a":2}')
139
+ assert _check_flags("row", row.columns, shared) == (PINNED_POISON_CELLS, PINNED_DUP_CELLS)
140
+
141
+ dup_transforms = [
142
+ t
143
+ for t in row.transformed
144
+ if t.reason == "duplicate_key_dropped" and t.column == b"dup.JSONEachRow.repeated_top_key"
145
+ ]
146
+ assert len(dup_transforms) == 1
147
+
148
+ batch = schema.rows(Format.TSV, b"1\n")
149
+ counts = [_check_flags(f"batch.rows[{i}]", r.columns, shared) for i, r in enumerate(batch.rows)]
150
+ assert (sum(p for p, _ in counts), sum(d for _, d in counts)) == (
151
+ PINNED_POISON_CELLS,
152
+ PINNED_DUP_CELLS,
153
+ )
@@ -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.dev14"
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