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.
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/CHANGELOG.md +1 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/PKG-INFO +1 -1
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/pyproject.toml +1 -1
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_ensure.py +36 -3
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_oci.py +41 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_preseed_scan.py +120 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/uv.lock +1 -1
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/.gitignore +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/LICENSE +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/README.md +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/__init__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/__main__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/__init__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_decls.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_errmap.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_errors.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_loader.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_abi2/_vocab.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_advisory.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_codes.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_decode.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ed25519.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_guard.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_input.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/__init__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_channel.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_constants.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_dsse.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_errors.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_faults.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_goldens.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_hold.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_http.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_layout.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_lock.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_prune.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_referrers.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_resolve.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_retired.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_revocation.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_ocifetch/_unpack.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/_setup.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/errors.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/library.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/py.typed +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/registry.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/src/chtypes/results.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/__init__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/_stub_tree.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/conftest.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_conformance.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_dev_fingerprint.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_goldens_runner_stub.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_loader.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_library.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_open.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_registry.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_server.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_public_setup_cases.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_reader_rules.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_registry_fetch.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/abi2/test_registry_flight.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/__init__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/test_decode.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/test_input.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/test_trusted_keys.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/api/test_vocab.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/__init__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/conftest.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/test_devchannel.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/test_main.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/test_resolve_prune.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/cli/test_revocation_cli.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/conftest.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/goldens_v1/__init__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/goldens_v1/data/stub-goldens.json +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/goldens_v1/test_runner.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/__init__.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/_bundle_support.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/_registry_support.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/_sign_support.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/conftest.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_cache_roots.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_cache_strict.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_conformance.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_devchannel.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_dsse.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_ensure.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_goldens.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_http.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_layout.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_lock.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_oci.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_prodv2_smoke.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_prodv2channel.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_retired.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_revocation.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_revocation_statement.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_seam_gate.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/ocifetch/test_unpack.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_abi_identity.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_cache_roots_api.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_devchannel_registry.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_ed25519.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_examples_tour.py +0 -0
- {chtypes-2.0.0.dev12 → chtypes-2.0.0.dev13}/tests/test_fetch_options.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|