chtypes 2.0.0.dev11__tar.gz → 2.0.0.dev12__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.dev11 → chtypes-2.0.0.dev12}/CHANGELOG.md +7 -1
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/PKG-INFO +3 -3
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/README.md +2 -2
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/pyproject.toml +1 -1
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/__init__.py +3 -2
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_ensure.py +70 -16
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/library.py +9 -3
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_public_library.py +18 -1
- chtypes-2.0.0.dev12/tests/ocifetch/test_preseed_scan.py +161 -0
- chtypes-2.0.0.dev12/tests/ocifetch/test_revocation_statement.py +240 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/uv.lock +1 -1
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/.gitignore +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/LICENSE +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/__main__.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_abi2/__init__.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_abi2/_decls.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_abi2/_errmap.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_abi2/_errors.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_abi2/_loader.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_abi2/_vocab.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_advisory.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_codes.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_decode.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ed25519.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_guard.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_input.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/__init__.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_channel.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_constants.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_dsse.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_errors.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_faults.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_goldens.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_hold.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_http.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_layout.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_lock.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_oci.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_prune.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_referrers.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_resolve.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_retired.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_revocation.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_ocifetch/_unpack.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/_setup.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/errors.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/py.typed +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/registry.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/src/chtypes/results.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/__init__.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/_stub_tree.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/conftest.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_conformance.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_dev_fingerprint.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_goldens_runner_stub.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_loader.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_public_open.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_public_registry.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_public_server.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_public_setup_cases.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_reader_rules.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_registry_fetch.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/abi2/test_registry_flight.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/api/__init__.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/api/test_decode.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/api/test_input.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/api/test_trusted_keys.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/api/test_vocab.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/cli/__init__.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/cli/conftest.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/cli/test_devchannel.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/cli/test_main.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/cli/test_resolve_prune.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/cli/test_revocation_cli.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/conftest.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/goldens_v1/__init__.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/goldens_v1/data/stub-goldens.json +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/goldens_v1/test_runner.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/__init__.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/_bundle_support.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/_registry_support.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/_sign_support.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/conftest.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_cache_roots.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_cache_strict.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_conformance.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_devchannel.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_dsse.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_ensure.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_goldens.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_http.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_layout.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_lock.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_oci.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_prodv2_smoke.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_prodv2channel.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_retired.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_revocation.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_seam_gate.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/ocifetch/test_unpack.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/test_abi_identity.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/test_cache_roots_api.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/test_devchannel_registry.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/test_ed25519.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/test_examples_tour.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/test_fetch_options.py +0 -0
- {chtypes-2.0.0.dev11 → chtypes-2.0.0.dev12}/tests/test_support_v2_gen.py +0 -0
|
@@ -8,7 +8,9 @@ The four bindings in this repository are released together and give one answer,
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- 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.
|
|
11
12
|
- `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
|
+
- 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.
|
|
12
14
|
- `CODE_DECLINED` (ABI v2, -2): the code a declined row, outcome or verdict carries, generated from the description and exported from `chtypes`, as `bindings-v1.md` section 3 promises.
|
|
13
15
|
- `CallError.reason` (ABI v2): the error's catalog id, read through `chs_error_reason` and passed through verbatim (ASCII, empty if none), on all four call classes.
|
|
14
16
|
- Spec batch 2, the options documents (ABI v2; moves the dev fingerprint): keyword-only `insertable_columns`, `column_defaults` (a sequence of `ColumnDefault`), `verdict_basis` (`VerdictBasis`: `INPUT`, `EXPORT`) and `defer_defaults` (`DeferDefaults`: `NONE`, `SERVER`) build `insert_options` for `Schema.row`, `Schema.rows`, `Schema.parse_block` and `Filter.rows`; `param_encoding` (`ParamEncoding`: `ESCAPED`, `RAW`) builds `filter_options` for `Schema.compile_filter`; `posture` (`Posture`: `CREATE`, `LOAD`) builds `schema_options` for `Library.compile_table`. `insert_options_extra`, `filter_options_extra` and `schema_options_extra` pass a member the binding has no name for through as JSON text, so the library, which refuses an unknown key (rule r1), is the only judge. A document with no member set is not sent. `Filter.rows` takes `columns` (`chs_filter_eval_body` takes the column list).
|
|
@@ -34,7 +36,8 @@ The four bindings in this repository are released together and give one answer,
|
|
|
34
36
|
|
|
35
37
|
### Changed
|
|
36
38
|
|
|
37
|
-
- **Breaking (
|
|
39
|
+
- **Breaking (vs the 2.0.0-dev releases):** the batch preview's default `doc_flags` is now 0 (the lean result): `Schema.rows` without `doc_flags=` returns no per-value documents. Pass `doc_flags=DocFlags.ALL` explicitly to get every column's per-value documents.
|
|
40
|
+
- **Breaking (ABI v2 dev): `BatchResult.spans` is a tuple of `Span | None`**, one entry per `rows` entry and index-aligned with it: `row_spans` carries `null` for a row the export did not carry (a skipped row, or a row a filter cut), which reads as `None`. It was the exported rows' spans only. Code that reads `span.off` from every entry raises `AttributeError` on a `None` entry: check each entry before reading it.
|
|
38
41
|
- **Breaking (ABI v2 dev): `AtMergeEntry.row` is `AtMergeEntry.part_row`**, the document's `part_row`.
|
|
39
42
|
- A document's `code` can be -2 (`CHS_CODE_DECLINED`) for a row, batch, filter result or verdict this build declined, and every decline names its `reason`.
|
|
40
43
|
- The refusal of a settings, query-parameter, default or server-settings value that is not valid UTF-8 is gone (it is the `{"b64": ...}` form now); a key that is not valid UTF-8, a macro and a server timezone are still refused, as a `UsageError` naming the input.
|
|
@@ -79,6 +82,7 @@ The four bindings in this repository are released together and give one answer,
|
|
|
79
82
|
- **Enforcement gate lifted** for `26.8` on `darwin-arm64`.
|
|
80
83
|
- **Enforcement gate lifted** for `26.9` on `darwin-arm64`.
|
|
81
84
|
- `docs/limitations.md` is re-checked against artifact build `20261012.142240` (v2-dev.13), the library half measured on that build: functions that need a server, in a column expression, and a body the server may read serially or in parallel under a non-zero error budget are listed under "Declined by design"; the known divergence for a column expression that calls a function needing a server is retired, since that build declines those schemas (on 26.8 and 26.9 the `AI*` and `ai*` functions compile, as the page says); and the `lossy` raw-NUL TSV gap is marked fixed on that build and still present in the 1.0 libraries. `docs/guides/filters.md` gains a section on subcolumn filters and a JSON value typed by its batch.
|
|
85
|
+
- The ABI v2 spec now states the INSERT-time settings layering the library already uses, since artifact build `20261021.025722` (#632): at the INSERT, the schema's own settings (the compile profile) reach only the parse, minus a type gate, and every expression the INSERT evaluates and its limits (`max_partitions_per_insert_block` among them) run under the call's own settings, the server profile and the setup defaults only. A setting your INSERT session carries for those goes in the call's settings or the server profile (`docs/guides/settings.md`). No API change.
|
|
82
86
|
|
|
83
87
|
### Removed
|
|
84
88
|
|
|
@@ -87,6 +91,8 @@ The four bindings in this repository are released together and give one answer,
|
|
|
87
91
|
|
|
88
92
|
### Fixed
|
|
89
93
|
|
|
94
|
+
- 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
|
+
- 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`.
|
|
90
96
|
- 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.
|
|
91
97
|
- A chunked response body cut off before its terminating zero-length chunk is held to the same rule (#644): the new `body-cut-off-chunked-then-ok` and `body-cut-off-chunked-exhausted` conformance cases cut one on the layer blob GET, and this binding already retried it and ended as `CHTYPES_SOURCE_UNREACHABLE` once the retry table was spent. No behavior change; the case now holds it.
|
|
92
98
|
- A settings or query-parameter key or value that is not valid UTF-8 is refused as a usage error; it was silently replaced with U+FFFD, so the library saw a different value. A string holding a lone surrogate (what `surrogateescape` makes of undecodable bytes) was sent as a `\udXXX` escape; the same inputs are refused now, for the per-call zone, the setup defaults and the server profile too. Valid UTF-8 serializes as before.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: chtypes
|
|
3
|
-
Version: 2.0.0.
|
|
3
|
+
Version: 2.0.0.dev12
|
|
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
|
|
@@ -43,7 +43,7 @@ uv add "chtypes>=2.0.0.dev0" # or: pip install "chtypes>=2.0.0.dev0"
|
|
|
43
43
|
|
|
44
44
|
```python
|
|
45
45
|
import chtypes
|
|
46
|
-
from chtypes import Format, Registry
|
|
46
|
+
from chtypes import DocFlags, Format, Registry
|
|
47
47
|
|
|
48
48
|
chtypes.setup(timezone="UTC") # once, before the first open (optional)
|
|
49
49
|
registry = Registry(autofetch=True)
|
|
@@ -51,7 +51,7 @@ library = registry.for_version("26.8") # a release line or an exact version
|
|
|
51
51
|
|
|
52
52
|
ddl = b"CREATE TABLE t (x UInt8, ts DateTime DEFAULT now()) ENGINE = Memory"
|
|
53
53
|
with library.compile_table(ddl) as schema:
|
|
54
|
-
batch = schema.rows(Format.JSON_EACH_ROW, b'{"x":256}\n')
|
|
54
|
+
batch = schema.rows(Format.JSON_EACH_ROW, b'{"x":256}\n', doc_flags=DocFlags.ALL)
|
|
55
55
|
|
|
56
56
|
row = batch.rows[0]
|
|
57
57
|
print(batch.outcome) # accepted
|
|
@@ -24,7 +24,7 @@ uv add "chtypes>=2.0.0.dev0" # or: pip install "chtypes>=2.0.0.dev0"
|
|
|
24
24
|
|
|
25
25
|
```python
|
|
26
26
|
import chtypes
|
|
27
|
-
from chtypes import Format, Registry
|
|
27
|
+
from chtypes import DocFlags, Format, Registry
|
|
28
28
|
|
|
29
29
|
chtypes.setup(timezone="UTC") # once, before the first open (optional)
|
|
30
30
|
registry = Registry(autofetch=True)
|
|
@@ -32,7 +32,7 @@ library = registry.for_version("26.8") # a release line or an exact version
|
|
|
32
32
|
|
|
33
33
|
ddl = b"CREATE TABLE t (x UInt8, ts DateTime DEFAULT now()) ENGINE = Memory"
|
|
34
34
|
with library.compile_table(ddl) as schema:
|
|
35
|
-
batch = schema.rows(Format.JSON_EACH_ROW, b'{"x":256}\n')
|
|
35
|
+
batch = schema.rows(Format.JSON_EACH_ROW, b'{"x":256}\n', doc_flags=DocFlags.ALL)
|
|
36
36
|
|
|
37
37
|
row = batch.rows[0]
|
|
38
38
|
print(batch.outcome) # accepted
|
|
@@ -13,14 +13,15 @@ C ABI. Nothing here reimplements a coercion rule, which is why the answers are
|
|
|
13
13
|
exact by construction rather than approximately right.
|
|
14
14
|
|
|
15
15
|
import chtypes
|
|
16
|
-
from chtypes import Format, Registry
|
|
16
|
+
from chtypes import DocFlags, Format, Registry
|
|
17
17
|
|
|
18
18
|
chtypes.setup(timezone="UTC") # once, before the first open
|
|
19
19
|
registry = Registry(autofetch=True)
|
|
20
20
|
library = registry.for_version("26.8") # a release line or an exact version
|
|
21
21
|
ddl = b"CREATE TABLE t (ts DateTime, seq UInt8) ENGINE = Memory"
|
|
22
22
|
with library.compile_table(ddl) as schema:
|
|
23
|
-
|
|
23
|
+
body = b'{"ts":"2026-01-15 10:30:00","seq":256}\\n'
|
|
24
|
+
batch = schema.rows(Format.JSON_EACH_ROW, body, doc_flags=DocFlags.ALL)
|
|
24
25
|
|
|
25
26
|
batch.outcome # Outcome.ACCEPTED: the insert would succeed
|
|
26
27
|
batch.transformed # the changes ClickHouse makes and reports as success
|
|
@@ -1296,6 +1296,48 @@ def _ensure(
|
|
|
1296
1296
|
raise _translate_transport_error(e) from e
|
|
1297
1297
|
|
|
1298
1298
|
|
|
1299
|
+
def _local_bundle_referrers(
|
|
1300
|
+
blobs_dir: Path, skip: Collection[str], known: dict[str, bytes] | None = None
|
|
1301
|
+
) -> dict[str, list[tuple[str, dict]]]:
|
|
1302
|
+
"""Each subject manifest digest's signature referrer manifests among
|
|
1303
|
+
`blobs_dir`'s blobs, as (blob name, parsed manifest), in listing order.
|
|
1304
|
+
A referrer need not be listed in `index.json`, so every blob that could
|
|
1305
|
+
be one is read, once per lookup rather than once per entry. A blob named
|
|
1306
|
+
in `skip` (the caller's candidate layers) and a blob larger than
|
|
1307
|
+
`limits.manifest_bytes`, which cannot be a manifest, are never read, so
|
|
1308
|
+
no layer is read here, and a blob in `known` (bytes the caller already
|
|
1309
|
+
read, by name) is not read again (public issue #690)."""
|
|
1310
|
+
out: dict[str, list[tuple[str, dict]]] = {}
|
|
1311
|
+
try:
|
|
1312
|
+
blob_paths = list(blobs_dir.iterdir())
|
|
1313
|
+
except OSError:
|
|
1314
|
+
return out
|
|
1315
|
+
for blob_path in blob_paths:
|
|
1316
|
+
if blob_path.name in skip:
|
|
1317
|
+
continue
|
|
1318
|
+
try:
|
|
1319
|
+
body = known.get(blob_path.name) if known is not None else None
|
|
1320
|
+
if body is None:
|
|
1321
|
+
if blob_path.stat().st_size > C.MANIFEST_MAX_BYTES:
|
|
1322
|
+
continue
|
|
1323
|
+
body = blob_path.read_bytes()
|
|
1324
|
+
doc = json.loads(body)
|
|
1325
|
+
except (ValueError, OSError, UnicodeDecodeError):
|
|
1326
|
+
continue
|
|
1327
|
+
if not isinstance(doc, dict) or doc.get("mediaType") != C.MEDIA_TYPE_MANIFEST:
|
|
1328
|
+
continue
|
|
1329
|
+
subject = doc.get("subject") or {}
|
|
1330
|
+
subject_digest = subject.get("digest") if isinstance(subject, dict) else None
|
|
1331
|
+
if not isinstance(subject_digest, str):
|
|
1332
|
+
continue
|
|
1333
|
+
# A signature referrer only: a revocation of the manifest carries the same
|
|
1334
|
+
# bundle media type, and is not the signature of the artifact.
|
|
1335
|
+
if doc.get("artifactType") != C.MEDIA_TYPE_BUNDLE:
|
|
1336
|
+
continue
|
|
1337
|
+
out.setdefault(subject_digest, []).append((blob_path.name, doc))
|
|
1338
|
+
return out
|
|
1339
|
+
|
|
1340
|
+
|
|
1299
1341
|
def _verify_and_install_from_local_blobs(
|
|
1300
1342
|
*,
|
|
1301
1343
|
source_root: Path,
|
|
@@ -1303,6 +1345,7 @@ def _verify_and_install_from_local_blobs(
|
|
|
1303
1345
|
cache_write_root: Path,
|
|
1304
1346
|
trusted_keys: tuple[TrustedKey, ...],
|
|
1305
1347
|
visible_only: bool = False,
|
|
1348
|
+
referrers: dict[str, list[tuple[str, dict]]] | None = None,
|
|
1306
1349
|
) -> VerifiedRecord | None:
|
|
1307
1350
|
"""docs/guides/fetch-v1.md §1/§6: "A pre-seeded layout has entries in
|
|
1308
1351
|
`index.json` with no corresponding `unpacked/` directory yet; the first
|
|
@@ -1328,23 +1371,12 @@ def _verify_and_install_from_local_blobs(
|
|
|
1328
1371
|
except (ValueError, OSError, FetchError):
|
|
1329
1372
|
return None
|
|
1330
1373
|
|
|
1374
|
+
if referrers is None:
|
|
1375
|
+
referrers = _local_bundle_referrers(blobs_dir, {parse_digest(layer_desc.digest)})
|
|
1331
1376
|
verified = None
|
|
1332
1377
|
bundle_digest = None
|
|
1333
1378
|
bundle_manifest_digest = None
|
|
1334
|
-
for
|
|
1335
|
-
try:
|
|
1336
|
-
doc = json.loads(blob_path.read_bytes())
|
|
1337
|
-
except (ValueError, OSError, UnicodeDecodeError):
|
|
1338
|
-
continue
|
|
1339
|
-
if not isinstance(doc, dict) or doc.get("mediaType") != C.MEDIA_TYPE_MANIFEST:
|
|
1340
|
-
continue
|
|
1341
|
-
subject = doc.get("subject") or {}
|
|
1342
|
-
if subject.get("digest") != manifest_digest:
|
|
1343
|
-
continue
|
|
1344
|
-
# A signature referrer only: a revocation of the manifest carries the same
|
|
1345
|
-
# bundle media type, and is not the signature of the artifact.
|
|
1346
|
-
if doc.get("artifactType") != C.MEDIA_TYPE_BUNDLE:
|
|
1347
|
-
continue
|
|
1379
|
+
for referrer_name, doc in referrers.get(manifest_digest, ()):
|
|
1348
1380
|
try:
|
|
1349
1381
|
referrer_layer = manifest_single_layer(doc, expected_media_type=C.MEDIA_TYPE_BUNDLE)
|
|
1350
1382
|
bundle_bytes = (blobs_dir / parse_digest(referrer_layer.digest)).read_bytes()
|
|
@@ -1354,7 +1386,7 @@ def _verify_and_install_from_local_blobs(
|
|
|
1354
1386
|
if candidate is not None:
|
|
1355
1387
|
verified = candidate
|
|
1356
1388
|
bundle_digest = referrer_layer.digest
|
|
1357
|
-
bundle_manifest_digest = "sha256:" +
|
|
1389
|
+
bundle_manifest_digest = "sha256:" + referrer_name
|
|
1358
1390
|
break
|
|
1359
1391
|
if verified is None:
|
|
1360
1392
|
return None
|
|
@@ -1422,7 +1454,9 @@ def _verify_preseeded_entries(roots: Sequence[Path], trusted_keys: tuple[Trusted
|
|
|
1422
1454
|
anywhere yet, and verifies+unpacks it from that root's OWN local blobs,
|
|
1423
1455
|
writing the result into `roots[0]` (the writable cache) regardless of
|
|
1424
1456
|
which root — including a read-only system directory — the blobs came
|
|
1425
|
-
from.
|
|
1457
|
+
from. An entry that already has a record is passed over without reading
|
|
1458
|
+
a blob, and each root's signature referrers are read once, never by
|
|
1459
|
+
reading a candidate's layer (public issue #690)."""
|
|
1426
1460
|
cache_write_root = roots[0]
|
|
1427
1461
|
already_verified = {record.manifest for _dir, record in list_verified_records(roots)}
|
|
1428
1462
|
for root in roots:
|
|
@@ -1431,16 +1465,36 @@ def _verify_preseeded_entries(roots: Sequence[Path], trusted_keys: tuple[Trusted
|
|
|
1431
1465
|
index_doc = json.loads(index_path.read_text(encoding="utf-8"))
|
|
1432
1466
|
except (OSError, ValueError):
|
|
1433
1467
|
continue
|
|
1468
|
+
blobs_dir = root / "blobs" / "sha256"
|
|
1469
|
+
candidates: list[str] = []
|
|
1470
|
+
candidate_layers: set[str] = set()
|
|
1471
|
+
manifests_read: dict[str, bytes] = {}
|
|
1434
1472
|
for m in index_doc.get("manifests") or []:
|
|
1435
1473
|
digest = m.get("digest")
|
|
1436
1474
|
if not digest or digest in already_verified:
|
|
1437
1475
|
continue
|
|
1476
|
+
candidates.append(digest)
|
|
1477
|
+
try:
|
|
1478
|
+
manifest_hex = parse_digest(digest)
|
|
1479
|
+
manifest_bytes = (blobs_dir / manifest_hex).read_bytes()
|
|
1480
|
+
manifests_read[manifest_hex] = manifest_bytes
|
|
1481
|
+
layer = manifest_layer_descriptor(json.loads(manifest_bytes))
|
|
1482
|
+
candidate_layers.add(parse_digest(layer.digest))
|
|
1483
|
+
except (ValueError, OSError, FetchError):
|
|
1484
|
+
continue
|
|
1485
|
+
if not candidates:
|
|
1486
|
+
continue
|
|
1487
|
+
referrers = _local_bundle_referrers(blobs_dir, candidate_layers, manifests_read)
|
|
1488
|
+
for digest in candidates:
|
|
1489
|
+
if digest in already_verified:
|
|
1490
|
+
continue
|
|
1438
1491
|
record = _verify_and_install_from_local_blobs(
|
|
1439
1492
|
source_root=root,
|
|
1440
1493
|
manifest_digest=digest,
|
|
1441
1494
|
cache_write_root=cache_write_root,
|
|
1442
1495
|
trusted_keys=trusted_keys,
|
|
1443
1496
|
visible_only=True,
|
|
1497
|
+
referrers=referrers,
|
|
1444
1498
|
)
|
|
1445
1499
|
if record is not None:
|
|
1446
1500
|
already_verified.add(digest)
|
|
@@ -54,6 +54,8 @@ __all__ = ["Block", "Filter", "Library", "Schema", "Server", "open_unverified"]
|
|
|
54
54
|
# One image per file, process-wide: keyed on the realpath's device and inode, so
|
|
55
55
|
# two registries, two spellings or a hardlink of one artifact share one image
|
|
56
56
|
# and one Library. Guarded by the setup lock, which an open holds throughout.
|
|
57
|
+
# The batch preview's default `doc_flags`: 0, the lean result (no per-value documents).
|
|
58
|
+
_LEAN_DOC_FLAGS = DocFlags(0)
|
|
57
59
|
_IMAGES: dict[tuple[int, int], Library] = {}
|
|
58
60
|
|
|
59
61
|
|
|
@@ -212,7 +214,8 @@ class Filter(_Handle):
|
|
|
212
214
|
insert_options_extra: Mapping[str, str] | None = None,
|
|
213
215
|
) -> FilterResult:
|
|
214
216
|
"""Evaluate over a body. `settings` and `session_timezone` are the body's
|
|
215
|
-
|
|
217
|
+
INSERT settings: they govern its parse and the expressions the INSERT
|
|
218
|
+
evaluates, never the WHERE, whose zone was fixed when the filter compiled.
|
|
216
219
|
`columns` is the INSERT column list and the keywords after it the
|
|
217
220
|
`insert_options` members, as for `Schema.rows`."""
|
|
218
221
|
payload = body_bytes(body)
|
|
@@ -304,11 +307,14 @@ class Schema(_Handle):
|
|
|
304
307
|
insert_options_extra: Mapping[str, str] | None = None,
|
|
305
308
|
row_filter: Filter | None = None,
|
|
306
309
|
export: Format | int | None = None,
|
|
307
|
-
doc_flags: DocFlags =
|
|
310
|
+
doc_flags: DocFlags = _LEAN_DOC_FLAGS,
|
|
308
311
|
) -> BatchResult:
|
|
309
312
|
"""A whole body. `export` asks for the accepted rows serialized in that
|
|
310
313
|
format (`payload`, and `spans`); to insert a body whose rows carry a
|
|
311
|
-
`default_generated` column, insert `payload`, never the original input.
|
|
314
|
+
`default_generated` column, insert `payload`, never the original input.
|
|
315
|
+
|
|
316
|
+
`doc_flags` chooses the document groups; absent means 0, the lean result
|
|
317
|
+
(no per-value documents). Pass `DocFlags.ALL` for every group."""
|
|
312
318
|
payload = body_bytes(body)
|
|
313
319
|
settings_bytes = settings_json(settings, session_timezone)
|
|
314
320
|
columns_bytes = columns_json(columns)
|
|
@@ -158,10 +158,27 @@ def test_rows_marshals_columns_filter_export_and_doc_flags(lib, monkeypatch) ->
|
|
|
158
158
|
assert fmt == 0 and body == b'{"a":1}\n' and settings is None and options is None
|
|
159
159
|
assert json.loads(columns) == [{"name": "a"}, {"name_b64": "/w=="}]
|
|
160
160
|
assert handle is not None and export == int(Format.CSV) and flags == 3
|
|
161
|
-
assert plain[5] is None and plain[6] == -1 and plain[7] ==
|
|
161
|
+
assert plain[5] is None and plain[6] == -1 and plain[7] == 0 # no export, lean (0)
|
|
162
162
|
assert plain[2] == b"1\n" # a bytes-like body is copied to bytes
|
|
163
163
|
|
|
164
164
|
|
|
165
|
+
def test_rows_doc_flags_default_is_lean_and_explicit_all_reaches_the_call(lib, monkeypatch) -> None:
|
|
166
|
+
calls: list[tuple] = []
|
|
167
|
+
real = lib._api.preview_batch
|
|
168
|
+
|
|
169
|
+
def spy(*args):
|
|
170
|
+
calls.append(args)
|
|
171
|
+
return real(*args)
|
|
172
|
+
|
|
173
|
+
monkeypatch.setattr(lib._api, "preview_batch", spy)
|
|
174
|
+
with lib.compile_table(CREATE) as schema:
|
|
175
|
+
schema.rows(Format.CSV, b"1\n")
|
|
176
|
+
schema.rows(Format.CSV, b"1\n", export=Format.CSV)
|
|
177
|
+
schema.rows(Format.CSV, b"1\n", doc_flags=DocFlags.ALL)
|
|
178
|
+
# Argument 7 of chs_preview_batch is doc_flags, as the real stub call received it.
|
|
179
|
+
assert [c[7] for c in calls] == [0, 0, int(DocFlags.ALL)]
|
|
180
|
+
|
|
181
|
+
|
|
165
182
|
def test_no_export_means_no_payload(lib) -> None:
|
|
166
183
|
with lib.compile_table(CREATE) as schema:
|
|
167
184
|
assert schema.rows(Format.CSV, b"1\n").payload is None
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Public issue #690: an offline lookup reads a bounded number of blobs,
|
|
2
|
+
whatever the number of builds the cache holds.
|
|
3
|
+
|
|
4
|
+
The verdicts come from the blob reads the lookup itself made (every
|
|
5
|
+
`Path.read_bytes` under the layout's `blobs/sha256/`), never from a timing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
from collections import Counter
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from chtypes._ocifetch._dsse import TrustedKey
|
|
20
|
+
from chtypes._ocifetch._ensure import Options, Request, resolve_installed
|
|
21
|
+
|
|
22
|
+
FIXTURES = Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "fetch-v1"
|
|
23
|
+
LAYOUT = FIXTURES / "layouts" / "offline-hit"
|
|
24
|
+
# The offline-hit layout's one build: 26.1.1.1 on linux-arm64.
|
|
25
|
+
BUILD_MANIFEST = "sha256:b461315c30649fec6d9cc988330ed3081ffd6a2a45aa21acda6ff212413f8637"
|
|
26
|
+
BUILD_LAYER_HEX = "df72d6f06f8ed738ecdd8e080dbd71244b66024daee20c85f53d3f822a49463b"
|
|
27
|
+
BUNDLE_REF_HEX = "887b88511be68e82d653c734f57fc0e2c9c2e64bda4f23f25fe0ae1648f6244b"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _key(name: str) -> TrustedKey:
|
|
31
|
+
public = bytes.fromhex((FIXTURES / name / "public.hex").read_text().strip())
|
|
32
|
+
return TrustedKey(keyid=hashlib.sha256(public).hexdigest()[:16], public_key=public)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _seed_clones(tmp_path: Path, n: int) -> tuple[Path, list[str]]:
|
|
36
|
+
"""The offline-hit layout plus n-1 more pre-seeded entries of the same
|
|
37
|
+
signed build: each its own manifest (the original plus one annotation),
|
|
38
|
+
its own signature referrer manifest naming it, and its own copy of the
|
|
39
|
+
bundle (the original bytes plus trailing whitespace, which the signature
|
|
40
|
+
does not cover). The layer stays the one signed layer."""
|
|
41
|
+
if not LAYOUT.is_dir():
|
|
42
|
+
pytest.skip(f"fetch fixtures not available at {LAYOUT}")
|
|
43
|
+
cache = tmp_path / f"cache-{n}"
|
|
44
|
+
shutil.copytree(LAYOUT, cache)
|
|
45
|
+
blobs = cache / "blobs" / "sha256"
|
|
46
|
+
|
|
47
|
+
def put(b: bytes) -> tuple[str, int]:
|
|
48
|
+
h = hashlib.sha256(b).hexdigest()
|
|
49
|
+
(blobs / h).write_bytes(b)
|
|
50
|
+
return "sha256:" + h, len(b)
|
|
51
|
+
|
|
52
|
+
index = json.loads((cache / "index.json").read_text())
|
|
53
|
+
first = index["manifests"][0]
|
|
54
|
+
digests = [first["digest"]]
|
|
55
|
+
manifest = json.loads((blobs / BUILD_MANIFEST.removeprefix("sha256:")).read_bytes())
|
|
56
|
+
ref = json.loads((blobs / BUNDLE_REF_HEX).read_bytes())
|
|
57
|
+
bundle_desc = ref["layers"][0]
|
|
58
|
+
bundle = (blobs / bundle_desc["digest"].removeprefix("sha256:")).read_bytes()
|
|
59
|
+
for i in range(1, n):
|
|
60
|
+
manifest["annotations"] = {"test.clone": str(i)}
|
|
61
|
+
md, msize = put(json.dumps(manifest).encode())
|
|
62
|
+
bd, bsize = put(bundle + b" " * i)
|
|
63
|
+
bundle_desc["digest"], bundle_desc["size"] = bd, bsize
|
|
64
|
+
ref["subject"] = {
|
|
65
|
+
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
|
66
|
+
"digest": md,
|
|
67
|
+
"size": msize,
|
|
68
|
+
}
|
|
69
|
+
put(json.dumps(ref).encode())
|
|
70
|
+
entry = {k: v for k, v in first.items() if k != "annotations"}
|
|
71
|
+
entry["digest"], entry["size"] = md, msize
|
|
72
|
+
index["manifests"].append(entry)
|
|
73
|
+
digests.append(md)
|
|
74
|
+
(cache / "index.json").write_text(json.dumps(index))
|
|
75
|
+
return cache, digests
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _blob_reads(monkeypatch: pytest.MonkeyPatch, cache: Path, fn: Callable[[], object]) -> Counter:
|
|
79
|
+
"""Every blob of `cache` read while fn runs, by name."""
|
|
80
|
+
reads: Counter = Counter()
|
|
81
|
+
blobs = cache / "blobs" / "sha256"
|
|
82
|
+
real = Path.read_bytes
|
|
83
|
+
|
|
84
|
+
def counting(self: Path) -> bytes:
|
|
85
|
+
data = real(self)
|
|
86
|
+
if self.parent == blobs:
|
|
87
|
+
reads[self.name] += 1
|
|
88
|
+
return data
|
|
89
|
+
|
|
90
|
+
with monkeypatch.context() as m:
|
|
91
|
+
m.setattr(Path, "read_bytes", counting)
|
|
92
|
+
fn()
|
|
93
|
+
return reads
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _options(cache: Path, key: str) -> Options:
|
|
97
|
+
return Options(cache_dir=cache, system_dirs=(), trusted_keys=(_key(key),))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.parametrize("n", [2, 20])
|
|
101
|
+
def test_lookup_over_unverifiable_entries_reads_no_layer(
|
|
102
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, n: int
|
|
103
|
+
) -> None:
|
|
104
|
+
"""N pre-seeded entries that no lookup installs (their signature verifies
|
|
105
|
+
under no trusted key), so every lookup meets them again: no layer is
|
|
106
|
+
read, no blob more than twice, and at most 5 reads per entry plus 2, at
|
|
107
|
+
N=2 and N=20 alike. The old scan read every blob of the layout, the layer
|
|
108
|
+
included, once per entry."""
|
|
109
|
+
cache, _ = _seed_clones(tmp_path, n)
|
|
110
|
+
options = _options(cache, "other-key")
|
|
111
|
+
results: list[object] = []
|
|
112
|
+
reads = _blob_reads(
|
|
113
|
+
monkeypatch,
|
|
114
|
+
cache,
|
|
115
|
+
lambda: results.append(resolve_installed(Request("26.1"), "linux-arm64", options)),
|
|
116
|
+
)
|
|
117
|
+
assert results == [None]
|
|
118
|
+
total, most = sum(reads.values()), max(reads.values(), default=0)
|
|
119
|
+
print(
|
|
120
|
+
f"N={n}: {total} blob reads, {len(reads)} distinct, most {most}, "
|
|
121
|
+
f"layer {reads[BUILD_LAYER_HEX]}"
|
|
122
|
+
)
|
|
123
|
+
assert reads[BUILD_LAYER_HEX] == 0, f"N={n}: the lookup read the layer"
|
|
124
|
+
assert most <= 2, f"N={n}: one blob was read {most} times in one lookup"
|
|
125
|
+
assert total <= 5 * n + 2, f"N={n}: {total} blob reads in one lookup"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@pytest.mark.parametrize("n", [2, 20])
|
|
129
|
+
def test_miss_over_installed_builds_reads_no_blob(
|
|
130
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, n: int
|
|
131
|
+
) -> None:
|
|
132
|
+
"""The consumer's case: every entry is installed, so none is pre-seeded
|
|
133
|
+
(fetch-v1.md §6), and a miss reads no blob at all."""
|
|
134
|
+
cache, digests = _seed_clones(tmp_path, n)
|
|
135
|
+
options = _options(cache, "test-key")
|
|
136
|
+
# The first lookup installs every pre-seeded entry of the build.
|
|
137
|
+
first = resolve_installed(Request("26.1"), "linux-arm64", options)
|
|
138
|
+
assert first is not None
|
|
139
|
+
assert all(
|
|
140
|
+
(cache / "unpacked" / "sha256" / d.removeprefix("sha256:")).is_dir() for d in digests
|
|
141
|
+
)
|
|
142
|
+
results: list[object] = []
|
|
143
|
+
reads = _blob_reads(
|
|
144
|
+
monkeypatch,
|
|
145
|
+
cache,
|
|
146
|
+
lambda: results.append(resolve_installed(Request("26.3"), "linux-arm64", options)),
|
|
147
|
+
)
|
|
148
|
+
assert results == [None]
|
|
149
|
+
assert sum(reads.values()) == 0, f"N={n}: the miss read {dict(reads)}"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_preseeded_install_still_verifies_the_layer(tmp_path: Path) -> None:
|
|
153
|
+
"""The install is as strict as before: with the layer's bytes altered,
|
|
154
|
+
nothing is installed."""
|
|
155
|
+
cache, digests = _seed_clones(tmp_path, 20)
|
|
156
|
+
layer = cache / "blobs" / "sha256" / BUILD_LAYER_HEX
|
|
157
|
+
b = bytearray(layer.read_bytes())
|
|
158
|
+
b[-1] ^= 0xFF
|
|
159
|
+
layer.write_bytes(bytes(b))
|
|
160
|
+
assert resolve_installed(Request("26.1"), "linux-arm64", _options(cache, "test-key")) is None
|
|
161
|
+
assert not (cache / "unpacked" / "sha256" / digests[0].removeprefix("sha256:")).exists()
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""The shared revocation-statement table (tests/fixtures/revocation-statement/
|
|
2
|
+
cases.json), generated by the delivery side's registry tests and read by all four
|
|
3
|
+
bindings: which signed revocation statements count (docs/guides/fetch-v1.md,
|
|
4
|
+
"Build-level revocation", rule 5), and that the first verifying revocation in a
|
|
5
|
+
listing wins. The statement rows call `verify_revocation`; the listing rows drive
|
|
6
|
+
the real candidate loop through `check_installed` against a local registry."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
from chtypes._ocifetch import _channel
|
|
20
|
+
from chtypes._ocifetch import _constants as C
|
|
21
|
+
from chtypes._ocifetch._dsse import TrustedKey
|
|
22
|
+
from chtypes._ocifetch._ensure import (
|
|
23
|
+
Options,
|
|
24
|
+
Resolved,
|
|
25
|
+
check_installed,
|
|
26
|
+
reset_revocation_memo_for_tests,
|
|
27
|
+
)
|
|
28
|
+
from chtypes._ocifetch._errors import ArtifactRevokedError
|
|
29
|
+
from chtypes._ocifetch._layout import resolve_cache_root
|
|
30
|
+
from chtypes._ocifetch._revocation import Subject, revoked_text, verify_revocation
|
|
31
|
+
|
|
32
|
+
DIR = Path(__file__).resolve().parents[3] / "tests" / "fixtures" / "revocation-statement"
|
|
33
|
+
TABLE = DIR / "cases.json"
|
|
34
|
+
FIELDS = ("id", "url", "class", "summary", "superseded_by", "revoked_at")
|
|
35
|
+
|
|
36
|
+
if not TABLE.is_file():
|
|
37
|
+
pytest.skip(
|
|
38
|
+
f"SKIPPED: the shared revocation-statement table is not beside this checkout ({TABLE})",
|
|
39
|
+
allow_module_level=True,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_DOC = json.loads(TABLE.read_text(encoding="utf-8"))
|
|
43
|
+
_BUILD = json.loads((DIR / "build.predicate.json").read_text(encoding="utf-8"))
|
|
44
|
+
_KEY = bytes.fromhex((DIR / "key.pub.hex").read_text().strip())
|
|
45
|
+
_KEYS = (TrustedKey(keyid=hashlib.sha256(_KEY).hexdigest()[:16], public_key=_KEY),)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _advisory(a) -> dict: # noqa: ANN001
|
|
49
|
+
return {
|
|
50
|
+
"id": a.id,
|
|
51
|
+
"url": a.url,
|
|
52
|
+
"class": a.class_,
|
|
53
|
+
"summary": a.summary,
|
|
54
|
+
"superseded_by": a.superseded_by,
|
|
55
|
+
"revoked_at": a.revoked_at,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _digest(data: bytes) -> str:
|
|
60
|
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
def prod_v2() -> Iterator[None]:
|
|
65
|
+
restore = _channel.use_prod_v2_for_tests()
|
|
66
|
+
reset_revocation_memo_for_tests()
|
|
67
|
+
try:
|
|
68
|
+
yield
|
|
69
|
+
finally:
|
|
70
|
+
reset_revocation_memo_for_tests()
|
|
71
|
+
restore()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_the_table_has_rows() -> None:
|
|
75
|
+
assert _DOC["cases"] and _DOC["listings"], "an empty table would check nothing"
|
|
76
|
+
assert any(c["expect"] == "verifies" for c in _DOC["cases"])
|
|
77
|
+
assert any(r["expect"] == "revoked" for r in _DOC["listings"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("case", _DOC["cases"], ids=lambda c: c["name"])
|
|
81
|
+
def test_statement_case(case: dict) -> None:
|
|
82
|
+
sub = Subject(
|
|
83
|
+
manifest=_BUILD["manifest"],
|
|
84
|
+
layer=_BUILD["layer"],
|
|
85
|
+
predicate=_BUILD["predicate"],
|
|
86
|
+
version="26.8.15.10",
|
|
87
|
+
platform="linux-amd64",
|
|
88
|
+
build="20260101.000000",
|
|
89
|
+
request="26.8",
|
|
90
|
+
dir=None,
|
|
91
|
+
writable=False,
|
|
92
|
+
)
|
|
93
|
+
rev, reason = verify_revocation((DIR / case["bundle"]).read_bytes(), _KEYS, sub)
|
|
94
|
+
if case["expect"] == "ignored":
|
|
95
|
+
assert rev is None, f"{case['name']} verifies, want it refused"
|
|
96
|
+
assert reason, "refused with no reason"
|
|
97
|
+
return
|
|
98
|
+
assert case["expect"] == "verifies"
|
|
99
|
+
assert rev is not None, f"{case['name']} does not verify ({reason})"
|
|
100
|
+
assert len(rev.advisories) == 1
|
|
101
|
+
assert _advisory(rev.advisories[0]) == case["expect_advisory"]
|
|
102
|
+
has_fix = case["expect_advisory"]["superseded_by"] != ""
|
|
103
|
+
assert has_fix == ("; superseded by build " in revoked_text(sub, rev)), revoked_text(sub, rev)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _Registry:
|
|
107
|
+
"""Serves one build's referrers listing in order, each referrer's manifest and
|
|
108
|
+
its bundle; `served` maps a bundle file to the manifest digest it is served
|
|
109
|
+
under."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, referrers: list[str]) -> None:
|
|
112
|
+
objects: dict[str, bytes] = {}
|
|
113
|
+
self.served: dict[str, str] = {}
|
|
114
|
+
manifests = [
|
|
115
|
+
{
|
|
116
|
+
"mediaType": C.MEDIA_TYPE_MANIFEST,
|
|
117
|
+
"digest": _digest(b"signature"),
|
|
118
|
+
"size": 9,
|
|
119
|
+
"artifactType": C.MEDIA_TYPE_BUNDLE, # the listing must name a signature
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
for name in referrers:
|
|
123
|
+
bundle = (DIR / name).read_bytes()
|
|
124
|
+
layer = {
|
|
125
|
+
"mediaType": C.MEDIA_TYPE_BUNDLE,
|
|
126
|
+
"digest": _digest(bundle),
|
|
127
|
+
"size": len(bundle),
|
|
128
|
+
}
|
|
129
|
+
body = json.dumps(
|
|
130
|
+
{
|
|
131
|
+
"schemaVersion": 2,
|
|
132
|
+
"mediaType": C.MEDIA_TYPE_MANIFEST,
|
|
133
|
+
"artifactType": C.REVOCATION_ARTIFACT_TYPE,
|
|
134
|
+
"config": {
|
|
135
|
+
"mediaType": "application/vnd.oci.empty.v1+json",
|
|
136
|
+
"digest": _digest(b"{}"),
|
|
137
|
+
"size": 2,
|
|
138
|
+
},
|
|
139
|
+
"layers": [layer],
|
|
140
|
+
}
|
|
141
|
+
).encode()
|
|
142
|
+
digest = _digest(body)
|
|
143
|
+
self.served[name] = digest
|
|
144
|
+
objects["/manifests/" + digest] = body
|
|
145
|
+
objects["/blobs/" + layer["digest"]] = bundle
|
|
146
|
+
manifests.append(
|
|
147
|
+
{
|
|
148
|
+
"mediaType": C.MEDIA_TYPE_MANIFEST,
|
|
149
|
+
"digest": digest,
|
|
150
|
+
"size": len(body),
|
|
151
|
+
"artifactType": C.REVOCATION_ARTIFACT_TYPE,
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
objects["/referrers/" + _BUILD["manifest"]] = json.dumps(
|
|
155
|
+
{"schemaVersion": 2, "mediaType": C.MEDIA_TYPE_INDEX, "manifests": manifests}
|
|
156
|
+
).encode()
|
|
157
|
+
|
|
158
|
+
class Handler(BaseHTTPRequestHandler):
|
|
159
|
+
def log_message(self, *args) -> None: # noqa: D102
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
def do_GET(self) -> None: # noqa: N802
|
|
163
|
+
for suffix, body in objects.items():
|
|
164
|
+
if self.path.endswith(suffix):
|
|
165
|
+
self.send_response(200)
|
|
166
|
+
self.send_header("Content-Length", str(len(body)))
|
|
167
|
+
self.end_headers()
|
|
168
|
+
self.wfile.write(body)
|
|
169
|
+
return
|
|
170
|
+
self.send_response(404)
|
|
171
|
+
self.send_header("Content-Length", "0")
|
|
172
|
+
self.end_headers()
|
|
173
|
+
|
|
174
|
+
self._httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
175
|
+
self._httpd.daemon_threads = True
|
|
176
|
+
threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def base(self) -> str:
|
|
180
|
+
host, port = self._httpd.server_address[:2]
|
|
181
|
+
return f"http://{host}:{port}/chtypes/v2"
|
|
182
|
+
|
|
183
|
+
def stop(self) -> None:
|
|
184
|
+
self._httpd.shutdown()
|
|
185
|
+
self._httpd.server_close()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.parametrize("listing", _DOC["listings"], ids=lambda r: r["name"])
|
|
189
|
+
def test_listing_case(prod_v2: None, listing: dict, tmp_path: Path) -> None:
|
|
190
|
+
registry = _Registry(listing["referrers"])
|
|
191
|
+
try:
|
|
192
|
+
manifest = _BUILD["manifest"]
|
|
193
|
+
entry = resolve_cache_root(tmp_path) / "unpacked" / "sha256" / manifest.split(":")[1]
|
|
194
|
+
entry.mkdir(parents=True)
|
|
195
|
+
res = Resolved(
|
|
196
|
+
abi_generation=2,
|
|
197
|
+
platform="linux-amd64",
|
|
198
|
+
request="26.8",
|
|
199
|
+
version="26.8.15.10",
|
|
200
|
+
channel=None,
|
|
201
|
+
build="20260101.000000",
|
|
202
|
+
library_path=entry / "libchtypes.so",
|
|
203
|
+
dir=entry,
|
|
204
|
+
digests={"manifest": manifest, "layer": _BUILD["layer"], "index": None, "bundle": None},
|
|
205
|
+
predicate=_BUILD["predicate"],
|
|
206
|
+
signed_by="",
|
|
207
|
+
source="cache",
|
|
208
|
+
already_installed=True,
|
|
209
|
+
)
|
|
210
|
+
opts = Options(
|
|
211
|
+
bases=(registry.base,), cache_dir=tmp_path, system_dirs=(), trusted_keys=_KEYS
|
|
212
|
+
)
|
|
213
|
+
warnings: list[str] = []
|
|
214
|
+
revoked: ArtifactRevokedError | None = None
|
|
215
|
+
try:
|
|
216
|
+
warnings = check_installed(res, opts)
|
|
217
|
+
except ArtifactRevokedError as exc:
|
|
218
|
+
revoked = exc
|
|
219
|
+
warnings = list(getattr(exc, "warnings", ()))
|
|
220
|
+
finally:
|
|
221
|
+
registry.stop()
|
|
222
|
+
|
|
223
|
+
ignored = [registry.served[f] for f in listing["ignored"]]
|
|
224
|
+
dont_care = [registry.served[f] for f in listing.get("dont_care", [])]
|
|
225
|
+
for digest in ignored:
|
|
226
|
+
assert any(digest in w and "does not verify and is ignored" in w for w in warnings), (
|
|
227
|
+
f"no warning names ignored referrer {digest}: {warnings}"
|
|
228
|
+
)
|
|
229
|
+
for w in warnings:
|
|
230
|
+
assert "does not verify and is ignored" in w, f"unexpected warning {w!r}"
|
|
231
|
+
assert any(d in w for d in ignored + dont_care), f"warning names no listed referrer: {w!r}"
|
|
232
|
+
|
|
233
|
+
if listing["expect"] == "revoked":
|
|
234
|
+
assert revoked is not None, "the build was allowed, want CHTYPES_ARTIFACT_REVOKED"
|
|
235
|
+
rev = revoked.revocation
|
|
236
|
+
assert rev is not None and rev.signed and len(rev.advisories) == 1
|
|
237
|
+
assert _advisory(rev.advisories[0]) == listing["expect_advisory"]
|
|
238
|
+
else:
|
|
239
|
+
assert listing["expect"] == "fail-open"
|
|
240
|
+
assert revoked is None, "the build was refused, want it allowed"
|
|
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
|