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