mostlyright-data 0.9.0__py3-none-any.whl
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.
- mostlyright/data_harness/__init__.py +158 -0
- mostlyright/data_harness/acquisition/__init__.py +55 -0
- mostlyright/data_harness/acquisition/http.py +2773 -0
- mostlyright/data_harness/acquisition/parsing.py +809 -0
- mostlyright/data_harness/acquisition/ranges.py +495 -0
- mostlyright/data_harness/acquisition/result_download.py +360 -0
- mostlyright/data_harness/acquisition/retention_admission.py +248 -0
- mostlyright/data_harness/acquisition/sandbox.py +4888 -0
- mostlyright/data_harness/acquisition/url_policy.py +530 -0
- mostlyright/data_harness/agent_runtime.py +2743 -0
- mostlyright/data_harness/assets/logo-ink.svg +31 -0
- mostlyright/data_harness/backends/__init__.py +28 -0
- mostlyright/data_harness/backends/pandas_backend.py +350 -0
- mostlyright/data_harness/backends/polars_backend.py +366 -0
- mostlyright/data_harness/backends/protocol.py +124 -0
- mostlyright/data_harness/backends/reference.py +83 -0
- mostlyright/data_harness/backends/registry.py +55 -0
- mostlyright/data_harness/backends/restrictions.py +126 -0
- mostlyright/data_harness/canonical.py +333 -0
- mostlyright/data_harness/catalog_job.py +625 -0
- mostlyright/data_harness/cli.py +5398 -0
- mostlyright/data_harness/contracts.py +53 -0
- mostlyright/data_harness/coordinator.py +1307 -0
- mostlyright/data_harness/deploy.py +924 -0
- mostlyright/data_harness/deploy_target.py +312 -0
- mostlyright/data_harness/deployment_evidence.py +1067 -0
- mostlyright/data_harness/event_presentation.py +576 -0
- mostlyright/data_harness/events.py +2152 -0
- mostlyright/data_harness/fast_delimited.py +239 -0
- mostlyright/data_harness/fleet.py +237 -0
- mostlyright/data_harness/formats.py +236 -0
- mostlyright/data_harness/governors.py +1163 -0
- mostlyright/data_harness/hosted_bootstrap.py +972 -0
- mostlyright/data_harness/hosted_crawler.py +1115 -0
- mostlyright/data_harness/hosted_crawler_container_smoke.py +351 -0
- mostlyright/data_harness/hosted_crawler_fetch.py +423 -0
- mostlyright/data_harness/hosted_crawler_job.py +1277 -0
- mostlyright/data_harness/hosted_crawler_protocol.py +676 -0
- mostlyright/data_harness/hosted_dataset.py +1500 -0
- mostlyright/data_harness/hosted_deploy.py +3037 -0
- mostlyright/data_harness/hosted_handoff.py +62 -0
- mostlyright/data_harness/hosted_ingestion_contract.py +504 -0
- mostlyright/data_harness/hosted_ingestion_job.py +356 -0
- mostlyright/data_harness/hosted_ingestion_job_smoke.py +40 -0
- mostlyright/data_harness/hosted_session_container_smoke.py +194 -0
- mostlyright/data_harness/hosted_session_worker.py +3554 -0
- mostlyright/data_harness/hosted_session_worker_job_smoke.py +46 -0
- mostlyright/data_harness/hosted_worker.py +6784 -0
- mostlyright/data_harness/ingestion/__init__.py +56 -0
- mostlyright/data_harness/ingestion/contracts.py +461 -0
- mostlyright/data_harness/ingestion/faults.py +42 -0
- mostlyright/data_harness/ingestion/gcs_store.py +1162 -0
- mostlyright/data_harness/ingestion/spool.py +130 -0
- mostlyright/data_harness/ingestion/store.py +885 -0
- mostlyright/data_harness/key_seam.py +434 -0
- mostlyright/data_harness/linux_process_boundary.py +262 -0
- mostlyright/data_harness/local_contracts.py +2880 -0
- mostlyright/data_harness/local_search/__init__.py +5 -0
- mostlyright/data_harness/local_search/build_index.py +1087 -0
- mostlyright/data_harness/local_search/contracts.py +920 -0
- mostlyright/data_harness/local_search/query_trace.py +266 -0
- mostlyright/data_harness/local_search/retrieval.py +700 -0
- mostlyright/data_harness/local_search/sealed.py +474 -0
- mostlyright/data_harness/local_search/service.py +784 -0
- mostlyright/data_harness/nbrender/CONTRACT.md +212 -0
- mostlyright/data_harness/nbrender/__init__.py +12 -0
- mostlyright/data_harness/nbrender/chrome.py +359 -0
- mostlyright/data_harness/nbrender/code_body.py +266 -0
- mostlyright/data_harness/nbrender/document.py +407 -0
- mostlyright/data_harness/nbrender/frame.py +275 -0
- mostlyright/data_harness/nbrender/interactive.py +337 -0
- mostlyright/data_harness/nbrender/markdown_body.py +477 -0
- mostlyright/data_harness/nbrender/mr_components.py +134 -0
- mostlyright/data_harness/nbrender/outputs_data.py +595 -0
- mostlyright/data_harness/nbrender/outputs_rich.py +906 -0
- mostlyright/data_harness/nbrender/outputs_source.py +260 -0
- mostlyright/data_harness/nbrender/outputs_stage.py +176 -0
- mostlyright/data_harness/nbrender/outputs_text.py +400 -0
- mostlyright/data_harness/nbrender/parse.py +394 -0
- mostlyright/data_harness/nbrender/status.py +40 -0
- mostlyright/data_harness/nbrender/tokens.py +1295 -0
- mostlyright/data_harness/notebook.py +1710 -0
- mostlyright/data_harness/offline.py +2049 -0
- mostlyright/data_harness/operation_registry.py +1007 -0
- mostlyright/data_harness/operator_setup.py +239 -0
- mostlyright/data_harness/pipeline.py +6428 -0
- mostlyright/data_harness/plan_graph.py +2026 -0
- mostlyright/data_harness/preparation/__init__.py +104 -0
- mostlyright/data_harness/preparation/contracts.py +1017 -0
- mostlyright/data_harness/preparation/engine.py +221 -0
- mostlyright/data_harness/preparation/errors.py +14 -0
- mostlyright/data_harness/preparation/gates.py +751 -0
- mostlyright/data_harness/preparation/joins.py +574 -0
- mostlyright/data_harness/preparation/profile.py +384 -0
- mostlyright/data_harness/preparation/table.py +217 -0
- mostlyright/data_harness/preparation/transforms.py +568 -0
- mostlyright/data_harness/progress_events.py +534 -0
- mostlyright/data_harness/readers/__init__.py +46 -0
- mostlyright/data_harness/readers/containers.py +963 -0
- mostlyright/data_harness/readers/contracts.py +542 -0
- mostlyright/data_harness/readers/delimited.py +257 -0
- mostlyright/data_harness/readers/grib2/__init__.py +33 -0
- mostlyright/data_harness/readers/grib2/admission.py +722 -0
- mostlyright/data_harness/readers/grib2/decode.py +1009 -0
- mostlyright/data_harness/readers/grib2/geometry.py +1133 -0
- mostlyright/data_harness/readers/grib2/portable_math.py +501 -0
- mostlyright/data_harness/readers/json_tabular.py +485 -0
- mostlyright/data_harness/readers/registry.py +514 -0
- mostlyright/data_harness/readers/samples/README.md +110 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.0.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/cities.csv.gz +0 -0
- mostlyright/data_harness/readers/samples/archive.gzip/1.1.0/cities_one_stream/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.0.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/cities.tar +0 -0
- mostlyright/data_harness/readers/samples/archive.tar/1.1.0/cities_beside_a_directory_entry/expected.json +24 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/cities.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.0.0/cities_beside_a_second_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.1.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/dwd-station.zip +0 -0
- mostlyright/data_harness/readers/samples/archive.zip/1.2.0/dwd_semicolon_station_member/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/cities.csv +3 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/an_ordinary_comma_separated_table/expected.json +23 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/cities.tsv +5 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.0.0/quoted_fields_holding_the_delimiter/expected.json +25 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/expected.json +30 -0
- mostlyright/data_harness/readers/samples/delimited_text/1.1.0/an_hourly_observation_table_served_as_plain_text/observations.csv +5 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/expected.json +44 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.0.0/nested_hourly_observations/stations.json +1 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/expected.json +48 -0
- mostlyright/data_harness/readers/samples/json.tabular/1.1.0/an_observation_stream_served_as_plain_text/observations.ndjson +4 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/cities.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/an_ordinary_table_beside_a_second_sheet/expected.json +24 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.0.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/expected.json +27 -0
- mostlyright/data_harness/readers/samples/spreadsheet.xlsx/1.1.0/shares_the_workbook_had_already_computed/shares.xlsx +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/README.md +20 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/expected.json +55 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/gfs_2m_temperature/gfs-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/1.0.0/hrrr_categorical_rain/hrrr-categorical-rain.grib2 +0 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/expected.json +54 -0
- mostlyright/data_harness/readers/samples/weather.grib2/2.0.0/hrrr_2m_temperature/hrrr-2m-temperature.grib2 +0 -0
- mostlyright/data_harness/readers/samples.py +582 -0
- mostlyright/data_harness/readers/spreadsheet.py +803 -0
- mostlyright/data_harness/readers/tabular.py +510 -0
- mostlyright/data_harness/recipe.py +5321 -0
- mostlyright/data_harness/repair/__init__.py +78 -0
- mostlyright/data_harness/repair/adapters.py +274 -0
- mostlyright/data_harness/repair/contracts.py +872 -0
- mostlyright/data_harness/repair/coordinator.py +1099 -0
- mostlyright/data_harness/repair/errors.py +16 -0
- mostlyright/data_harness/review.py +2533 -0
- mostlyright/data_harness/rowset.py +283 -0
- mostlyright/data_harness/serving.py +1975 -0
- mostlyright/data_harness/serving_edge.py +590 -0
- mostlyright/data_harness/serving_http.py +1031 -0
- mostlyright/data_harness/session_probes.py +759 -0
- mostlyright/data_harness/signing.py +101 -0
- mostlyright/data_harness/source_discovery.py +898 -0
- mostlyright/data_harness/sources/__init__.py +209 -0
- mostlyright/data_harness/sources/_adapter_steps.py +213 -0
- mostlyright/data_harness/sources/adapters.py +1214 -0
- mostlyright/data_harness/sources/cadence.py +1428 -0
- mostlyright/data_harness/sources/cadence_emission.py +453 -0
- mostlyright/data_harness/sources/cadence_history.py +546 -0
- mostlyright/data_harness/sources/catalog/__init__.py +17 -0
- mostlyright/data_harness/sources/catalog/admission.py +477 -0
- mostlyright/data_harness/sources/catalog/authoring.py +1701 -0
- mostlyright/data_harness/sources/catalog/authoring_policy.py +701 -0
- mostlyright/data_harness/sources/catalog/authoring_shards.py +1217 -0
- mostlyright/data_harness/sources/catalog/bounded_io.py +231 -0
- mostlyright/data_harness/sources/catalog/channel.py +523 -0
- mostlyright/data_harness/sources/catalog/channel_client.py +296 -0
- mostlyright/data_harness/sources/catalog/contracts.py +825 -0
- mostlyright/data_harness/sources/catalog/coverage.py +137 -0
- mostlyright/data_harness/sources/catalog/delta.py +1340 -0
- mostlyright/data_harness/sources/catalog/embedding.py +532 -0
- mostlyright/data_harness/sources/catalog/entry_v2.py +1182 -0
- mostlyright/data_harness/sources/catalog/fill.py +3889 -0
- mostlyright/data_harness/sources/catalog/fill_partitions.py +459 -0
- mostlyright/data_harness/sources/catalog/fill_staging.py +1105 -0
- mostlyright/data_harness/sources/catalog/gating.py +374 -0
- mostlyright/data_harness/sources/catalog/generation_receipt.py +1607 -0
- mostlyright/data_harness/sources/catalog/harvest/__init__.py +7 -0
- mostlyright/data_harness/sources/catalog/harvest/ckan.py +384 -0
- mostlyright/data_harness/sources/catalog/harvest/datagov_v4.py +798 -0
- mostlyright/data_harness/sources/catalog/harvest/protocol.py +964 -0
- mostlyright/data_harness/sources/catalog/harvest/sdmx.py +445 -0
- mostlyright/data_harness/sources/catalog/harvest/stac.py +384 -0
- mostlyright/data_harness/sources/catalog/health.py +447 -0
- mostlyright/data_harness/sources/catalog/hosted_catalog.py +105 -0
- mostlyright/data_harness/sources/catalog/identity_history.py +1549 -0
- mostlyright/data_harness/sources/catalog/neural.py +1618 -0
- mostlyright/data_harness/sources/catalog/packed_catalog.py +2345 -0
- mostlyright/data_harness/sources/catalog/packed_retrieval.py +1517 -0
- mostlyright/data_harness/sources/catalog/packed_writer.py +2802 -0
- mostlyright/data_harness/sources/catalog/query_trace.py +1037 -0
- mostlyright/data_harness/sources/catalog/recommend.py +171 -0
- mostlyright/data_harness/sources/catalog/retrieval.py +230 -0
- mostlyright/data_harness/sources/catalog/retrieval_manifest.py +995 -0
- mostlyright/data_harness/sources/catalog/rights_decisions.py +254 -0
- mostlyright/data_harness/sources/catalog/sealed.py +560 -0
- mostlyright/data_harness/sources/catalog/search.py +230 -0
- mostlyright/data_harness/sources/catalog/streaming_delta.py +1097 -0
- mostlyright/data_harness/sources/catalog/update.py +891 -0
- mostlyright/data_harness/sources/collections.py +815 -0
- mostlyright/data_harness/sources/contracts.py +2223 -0
- mostlyright/data_harness/sources/deletion.py +761 -0
- mostlyright/data_harness/sources/fitness.py +162 -0
- mostlyright/data_harness/sources/governance.py +163 -0
- mostlyright/data_harness/sources/hosted.py +173 -0
- mostlyright/data_harness/sources/integration.py +218 -0
- mostlyright/data_harness/sources/range_reader.py +418 -0
- mostlyright/data_harness/sources/registry.py +514 -0
- mostlyright/data_harness/sources/rights_rule.py +59 -0
- mostlyright/data_harness/sources/source_cadence_vectors.v1.json +1 -0
- mostlyright/data_harness/sources/sports.py +521 -0
- mostlyright/data_harness/sources/stream.py +524 -0
- mostlyright/data_harness/sources/stream_connector.py +418 -0
- mostlyright/data_harness/sources/stream_recorder.py +1404 -0
- mostlyright/data_harness/studio_boundary.py +2019 -0
- mostlyright/data_harness/thin/__init__.py +37 -0
- mostlyright/data_harness/thin/acquire.py +1137 -0
- mostlyright/data_harness/thin/acquire_cancel.py +579 -0
- mostlyright/data_harness/thin/approvals.py +617 -0
- mostlyright/data_harness/thin/commands.py +406 -0
- mostlyright/data_harness/thin/download.py +194 -0
- mostlyright/data_harness/thin/narrative.py +589 -0
- mostlyright/data_harness/thin/parity.py +1070 -0
- mostlyright/data_harness/thin/propose.py +2759 -0
- mostlyright/data_harness/thin/research.py +1663 -0
- mostlyright/data_harness/thin/router.py +924 -0
- mostlyright/data_harness/thin/runs.py +519 -0
- mostlyright/data_harness/thin/session.py +281 -0
- mostlyright/data_harness/thin/stream.py +501 -0
- mostlyright/data_harness/thin/transport.py +187 -0
- mostlyright/data_harness/thin/vocabulary.py +368 -0
- mostlyright/data_harness/thin/workers.py +164 -0
- mostlyright/data_harness/ucum/TABLE-PIN.json +40 -0
- mostlyright/data_harness/ucum/ucum-subset.v1.json +632 -0
- mostlyright/data_harness/unit_flow.py +927 -0
- mostlyright/data_harness/units.py +572 -0
- mostlyright/data_harness/ux/__init__.py +9 -0
- mostlyright/data_harness/ux/approve.py +485 -0
- mostlyright/data_harness/ux/author_yaml.py +597 -0
- mostlyright/data_harness/ux/cloud_auth.py +447 -0
- mostlyright/data_harness/ux/commands/__init__.py +260 -0
- mostlyright/data_harness/ux/commands/approve.py +136 -0
- mostlyright/data_harness/ux/commands/auth.py +744 -0
- mostlyright/data_harness/ux/commands/author.py +79 -0
- mostlyright/data_harness/ux/commands/catalog_author.py +403 -0
- mostlyright/data_harness/ux/commands/catalog_fill.py +523 -0
- mostlyright/data_harness/ux/commands/catalog_harvest.py +545 -0
- mostlyright/data_harness/ux/commands/catalog_publish.py +1838 -0
- mostlyright/data_harness/ux/commands/catalog_search.py +71 -0
- mostlyright/data_harness/ux/commands/catalog_update.py +437 -0
- mostlyright/data_harness/ux/commands/deploy.py +134 -0
- mostlyright/data_harness/ux/commands/deploy_dataset.py +98 -0
- mostlyright/data_harness/ux/commands/deploy_plan.py +105 -0
- mostlyright/data_harness/ux/commands/deploy_status.py +104 -0
- mostlyright/data_harness/ux/commands/diff.py +74 -0
- mostlyright/data_harness/ux/commands/index.py +84 -0
- mostlyright/data_harness/ux/commands/inventory.py +47 -0
- mostlyright/data_harness/ux/commands/list_builds.py +143 -0
- mostlyright/data_harness/ux/commands/login.py +63 -0
- mostlyright/data_harness/ux/commands/peek.py +236 -0
- mostlyright/data_harness/ux/commands/plan_check.py +90 -0
- mostlyright/data_harness/ux/commands/preflight.py +97 -0
- mostlyright/data_harness/ux/commands/record.py +107 -0
- mostlyright/data_harness/ux/commands/review_setup.py +47 -0
- mostlyright/data_harness/ux/commands/search.py +440 -0
- mostlyright/data_harness/ux/commands/show.py +61 -0
- mostlyright/data_harness/ux/commands/whoami.py +37 -0
- mostlyright/data_harness/ux/credential_native.py +551 -0
- mostlyright/data_harness/ux/credential_store.py +1055 -0
- mostlyright/data_harness/ux/credentials.py +631 -0
- mostlyright/data_harness/ux/diffing.py +444 -0
- mostlyright/data_harness/ux/headline.py +671 -0
- mostlyright/data_harness/ux/hosted_acquisition.py +974 -0
- mostlyright/data_harness/ux/hosted_run_status.py +619 -0
- mostlyright/data_harness/ux/inventory.py +427 -0
- mostlyright/data_harness/ux/local_review.py +375 -0
- mostlyright/data_harness/ux/login.py +691 -0
- mostlyright/data_harness/ux/path_kind.py +147 -0
- mostlyright/data_harness/ux/peek.py +1000 -0
- mostlyright/data_harness/ux/plain_file.py +178 -0
- mostlyright/data_harness/ux/plan_check.py +311 -0
- mostlyright/data_harness/ux/preflight.py +918 -0
- mostlyright/data_harness/ux/readers.py +1124 -0
- mostlyright/data_harness/ux/remediation.py +2195 -0
- mostlyright/data_harness/ux/render.py +657 -0
- mostlyright/data_harness/ux/workload.py +1077 -0
- mostlyright/data_harness/viewer.py +3713 -0
- mostlyright/data_harness/visual_run/__init__.py +83 -0
- mostlyright/data_harness/visual_run/authoring.py +235 -0
- mostlyright/data_harness/visual_run/contracts.py +673 -0
- mostlyright/data_harness/visual_run/materialize.py +486 -0
- mostlyright/data_harness/visual_run/observations.py +874 -0
- mostlyright/data_harness/visual_run/query.py +259 -0
- mostlyright/data_harness/visual_run/reducer.py +280 -0
- mostlyright/data_harness/visual_run/sdk.py +892 -0
- mostlyright/data_harness/visual_run/store.py +584 -0
- mostlyright/data_harness/visual_run/transport.py +239 -0
- mostlyright/data_harness/watch.py +2999 -0
- mostlyright_data-0.9.0.dist-info/METADATA +607 -0
- mostlyright_data-0.9.0.dist-info/RECORD +314 -0
- mostlyright_data-0.9.0.dist-info/WHEEL +4 -0
- mostlyright_data-0.9.0.dist-info/entry_points.txt +12 -0
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
"""Installing a published catalog generation onto this computer, with no service in between.
|
|
2
|
+
|
|
3
|
+
A generation is distributed as one zip archive whose exact SHA-256 the operator publishes next
|
|
4
|
+
to it. The archive holds the generation's folder under one ``catalog/`` prefix plus one
|
|
5
|
+
``release.json`` naming the store kind, the store file's digest, and the digest of the store file
|
|
6
|
+
this release descends from. Trust flows from exactly two places and nowhere else: the archive
|
|
7
|
+
digest the person typed, and the state this computer recorded when it installed the current
|
|
8
|
+
generation. There is no ambient discovery, no version service, and no clock: everything verified
|
|
9
|
+
here is a pure function of the archive bytes, the arguments, and the recorded state.
|
|
10
|
+
|
|
11
|
+
The descent statement is cross-checked against the store itself for a packed generation, whose
|
|
12
|
+
head names its own predecessor. A v2 retrieval store carries no predecessor field, so for that
|
|
13
|
+
kind the statement stands on the packaging invocation alone, bound by the archive digest: it is
|
|
14
|
+
the packager's word, published under the digest the person typed.
|
|
15
|
+
|
|
16
|
+
Installation is append-only. Each generation lands in its own folder named by its store digest,
|
|
17
|
+
written under a private staging name, verified in place by the real store loading -- the same
|
|
18
|
+
checks a search performs -- and only then made visible by one atomic rename; the recorded state
|
|
19
|
+
is replaced through the same append-only receipt discipline the publish lane uses, so the
|
|
20
|
+
previous state is retained rather than overwritten. A destination folder that already holds this
|
|
21
|
+
generation -- retained from an earlier install -- is re-verified and adopted rather than refused,
|
|
22
|
+
so rolling back to a retained generation and retrying after an interrupted state write both work.
|
|
23
|
+
|
|
24
|
+
The archive is screened the way the execution-coordinate wheel is screened: member names are
|
|
25
|
+
refused rather than sanitised (no absolute names, no ``..`` parts, no backslashes, no folder
|
|
26
|
+
members, no duplicate names -- including names that collide once case or accent normalisation is
|
|
27
|
+
applied, because the destination filesystem may apply either -- no links or special files, no
|
|
28
|
+
encrypted members), and byte budgets bound every member, the whole expansion, and the expansion
|
|
29
|
+
ratio, so a hostile archive is refused before a byte of it reaches the destination folder.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import io
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import stat
|
|
38
|
+
import unicodedata
|
|
39
|
+
import zipfile
|
|
40
|
+
import zlib
|
|
41
|
+
from collections.abc import Mapping
|
|
42
|
+
from dataclasses import dataclass
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from mostlyright.data_harness.canonical import (
|
|
47
|
+
CanonicalJSONError,
|
|
48
|
+
canonical_json_bytes,
|
|
49
|
+
parse_canonical_json,
|
|
50
|
+
sha256_bytes,
|
|
51
|
+
)
|
|
52
|
+
from mostlyright.data_harness.readers.containers import read_bounded, screen_zip_entry
|
|
53
|
+
from mostlyright.data_harness.readers.contracts import ReaderError
|
|
54
|
+
from mostlyright.data_harness.sources.catalog.generation_receipt import (
|
|
55
|
+
ARCHIVE_EXPANSION_FLOOR_BYTES,
|
|
56
|
+
ARCHIVE_EXPANSION_RATIO,
|
|
57
|
+
install_receipt,
|
|
58
|
+
is_digest,
|
|
59
|
+
read_bounded_document,
|
|
60
|
+
)
|
|
61
|
+
from mostlyright.data_harness.sources.catalog.packed_catalog import (
|
|
62
|
+
MAX_PACKED_HEAD_BYTES,
|
|
63
|
+
PACKED_HEAD_FILENAME,
|
|
64
|
+
verify_packed_generation,
|
|
65
|
+
)
|
|
66
|
+
from mostlyright.data_harness.sources.catalog.packed_retrieval import detect_public_store_kind
|
|
67
|
+
from mostlyright.data_harness.sources.catalog.retrieval_manifest import (
|
|
68
|
+
MAX_RETRIEVAL_MANIFEST_BYTES,
|
|
69
|
+
RETRIEVAL_MANIFEST_FILENAME,
|
|
70
|
+
load_retrieval_catalog,
|
|
71
|
+
)
|
|
72
|
+
from mostlyright.data_harness.sources.contracts import (
|
|
73
|
+
SourceContractError,
|
|
74
|
+
require_canonical_timestamp,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"ARCHIVE_MEMBER_PREFIX",
|
|
79
|
+
"GENERATIONS_DIRNAME",
|
|
80
|
+
"INSTALLED_STATE_FILENAME",
|
|
81
|
+
"INSTALLED_STATE_SCHEMA",
|
|
82
|
+
"MAX_RELEASE_ARCHIVE_BYTES",
|
|
83
|
+
"RELEASE_DESCRIPTOR_FILENAME",
|
|
84
|
+
"RELEASE_DESCRIPTOR_SCHEMA",
|
|
85
|
+
"SUPPORTED_STORE_KINDS",
|
|
86
|
+
"CatalogUpdateReport",
|
|
87
|
+
"InstalledState",
|
|
88
|
+
"ReleaseDescriptor",
|
|
89
|
+
"build_release_archive",
|
|
90
|
+
"confirm_already_current",
|
|
91
|
+
"install_release",
|
|
92
|
+
"load_installed_state",
|
|
93
|
+
"verify_release_archive",
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
RELEASE_DESCRIPTOR_FILENAME = "release.json"
|
|
97
|
+
RELEASE_DESCRIPTOR_SCHEMA = "mr-data-catalog-release.v1"
|
|
98
|
+
INSTALLED_STATE_FILENAME = "installed.json"
|
|
99
|
+
INSTALLED_STATE_SCHEMA = "mr-data-catalog-installed.v1"
|
|
100
|
+
GENERATIONS_DIRNAME = "generations"
|
|
101
|
+
ARCHIVE_MEMBER_PREFIX = "catalog/"
|
|
102
|
+
SUPPORTED_STORE_KINDS = ("packed", "retrieval_v2")
|
|
103
|
+
|
|
104
|
+
MAX_RELEASE_ARCHIVE_BYTES = 1_073_741_824
|
|
105
|
+
MAX_RELEASE_MEMBER_BYTES = 268_435_456
|
|
106
|
+
MAX_RELEASE_MEMBER_COUNT = 50_000
|
|
107
|
+
MAX_RELEASE_DESCRIPTOR_BYTES = 4_096
|
|
108
|
+
MAX_INSTALLED_STATE_BYTES = 65_536
|
|
109
|
+
MAX_RELEASE_ENTRY_COUNT = 10_000_000
|
|
110
|
+
MAX_MEMBER_NAME_PARTS = 16
|
|
111
|
+
MAX_MEMBER_NAME_PART_LENGTH = 255
|
|
112
|
+
|
|
113
|
+
_STORE_FILENAMES = {
|
|
114
|
+
"packed": PACKED_HEAD_FILENAME,
|
|
115
|
+
"retrieval_v2": RETRIEVAL_MANIFEST_FILENAME,
|
|
116
|
+
}
|
|
117
|
+
_STORE_FILE_MAXIMUMS = {
|
|
118
|
+
"packed": MAX_PACKED_HEAD_BYTES,
|
|
119
|
+
"retrieval_v2": MAX_RETRIEVAL_MANIFEST_BYTES,
|
|
120
|
+
}
|
|
121
|
+
_RELEASE_DESCRIPTOR_KEYS = frozenset(
|
|
122
|
+
{
|
|
123
|
+
"entry_count",
|
|
124
|
+
"predecessor_store_sha256",
|
|
125
|
+
"published_at",
|
|
126
|
+
"schema_version",
|
|
127
|
+
"store_kind",
|
|
128
|
+
"store_sha256",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
_INSTALLED_STATE_KEYS = frozenset(
|
|
132
|
+
{
|
|
133
|
+
"archive_sha256",
|
|
134
|
+
"entry_count",
|
|
135
|
+
"generation_path",
|
|
136
|
+
"predecessor_store_sha256",
|
|
137
|
+
"published_at",
|
|
138
|
+
"schema_version",
|
|
139
|
+
"store_kind",
|
|
140
|
+
"store_sha256",
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
# A generation folder is named by the first twelve characters of its store digest and by
|
|
144
|
+
# nothing else, so the recorded path is a closed shape rather than a screened one.
|
|
145
|
+
_GENERATION_FOLDER_PATTERN = re.compile(r"[0-9a-f]{12}")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _refuse(code: str, path: str, detail: str) -> SourceContractError:
|
|
149
|
+
return SourceContractError(code, path, detail)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _require_release_facts(
|
|
153
|
+
*,
|
|
154
|
+
store_kind: str,
|
|
155
|
+
store_sha256: str,
|
|
156
|
+
predecessor_store_sha256: str | None,
|
|
157
|
+
entry_count: int,
|
|
158
|
+
published_at: str,
|
|
159
|
+
code: str,
|
|
160
|
+
path: str,
|
|
161
|
+
) -> None:
|
|
162
|
+
"""The field rules a release statement and a recorded install state share."""
|
|
163
|
+
|
|
164
|
+
if store_kind not in SUPPORTED_STORE_KINDS:
|
|
165
|
+
raise _refuse(
|
|
166
|
+
"CATALOG_UPDATE_STORE", f"{path}.store_kind", "must name a supported store kind"
|
|
167
|
+
)
|
|
168
|
+
if not is_digest(store_sha256):
|
|
169
|
+
raise _refuse(code, f"{path}.store_sha256", "must be one exact lowercase SHA-256")
|
|
170
|
+
if predecessor_store_sha256 is not None:
|
|
171
|
+
if not is_digest(predecessor_store_sha256):
|
|
172
|
+
raise _refuse(
|
|
173
|
+
code,
|
|
174
|
+
f"{path}.predecessor_store_sha256",
|
|
175
|
+
"must be one exact lowercase SHA-256 or null",
|
|
176
|
+
)
|
|
177
|
+
if predecessor_store_sha256 == store_sha256:
|
|
178
|
+
raise _refuse(
|
|
179
|
+
code, f"{path}.predecessor_store_sha256", "must not name the release itself"
|
|
180
|
+
)
|
|
181
|
+
if (
|
|
182
|
+
not isinstance(entry_count, int)
|
|
183
|
+
or isinstance(entry_count, bool)
|
|
184
|
+
or not 0 <= entry_count <= MAX_RELEASE_ENTRY_COUNT
|
|
185
|
+
):
|
|
186
|
+
raise _refuse(code, f"{path}.entry_count", "must be a bounded whole number")
|
|
187
|
+
require_canonical_timestamp(published_at, f"{path}.published_at")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True)
|
|
191
|
+
class ReleaseDescriptor:
|
|
192
|
+
"""The one statement a release archive makes about itself."""
|
|
193
|
+
|
|
194
|
+
store_kind: str
|
|
195
|
+
store_sha256: str
|
|
196
|
+
predecessor_store_sha256: str | None
|
|
197
|
+
entry_count: int
|
|
198
|
+
published_at: str
|
|
199
|
+
|
|
200
|
+
def __post_init__(self) -> None:
|
|
201
|
+
_require_release_facts(
|
|
202
|
+
store_kind=self.store_kind,
|
|
203
|
+
store_sha256=self.store_sha256,
|
|
204
|
+
predecessor_store_sha256=self.predecessor_store_sha256,
|
|
205
|
+
entry_count=self.entry_count,
|
|
206
|
+
published_at=self.published_at,
|
|
207
|
+
code="CATALOG_UPDATE_RELEASE",
|
|
208
|
+
path="release",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def from_dict(cls, data: Any, *, path: str = "release") -> ReleaseDescriptor:
|
|
213
|
+
if not isinstance(data, dict):
|
|
214
|
+
raise _refuse("CATALOG_UPDATE_RELEASE", path, "must be one JSON object")
|
|
215
|
+
if set(data) != _RELEASE_DESCRIPTOR_KEYS:
|
|
216
|
+
raise _refuse(
|
|
217
|
+
"CATALOG_UPDATE_RELEASE",
|
|
218
|
+
path,
|
|
219
|
+
"must carry exactly the declared release fields",
|
|
220
|
+
)
|
|
221
|
+
if data["schema_version"] != RELEASE_DESCRIPTOR_SCHEMA:
|
|
222
|
+
raise _refuse(
|
|
223
|
+
"CATALOG_UPDATE_RELEASE",
|
|
224
|
+
f"{path}.schema_version",
|
|
225
|
+
f"must be {RELEASE_DESCRIPTOR_SCHEMA}",
|
|
226
|
+
)
|
|
227
|
+
return cls(
|
|
228
|
+
store_kind=data["store_kind"],
|
|
229
|
+
store_sha256=data["store_sha256"],
|
|
230
|
+
predecessor_store_sha256=data["predecessor_store_sha256"],
|
|
231
|
+
entry_count=data["entry_count"],
|
|
232
|
+
published_at=data["published_at"],
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def to_dict(self) -> dict[str, Any]:
|
|
236
|
+
return {
|
|
237
|
+
"schema_version": RELEASE_DESCRIPTOR_SCHEMA,
|
|
238
|
+
"store_kind": self.store_kind,
|
|
239
|
+
"store_sha256": self.store_sha256,
|
|
240
|
+
"predecessor_store_sha256": self.predecessor_store_sha256,
|
|
241
|
+
"entry_count": self.entry_count,
|
|
242
|
+
"published_at": self.published_at,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class InstalledState:
|
|
248
|
+
"""What this computer remembers about the generation it installed."""
|
|
249
|
+
|
|
250
|
+
store_kind: str
|
|
251
|
+
store_sha256: str
|
|
252
|
+
archive_sha256: str
|
|
253
|
+
predecessor_store_sha256: str | None
|
|
254
|
+
entry_count: int
|
|
255
|
+
published_at: str
|
|
256
|
+
generation_path: str
|
|
257
|
+
|
|
258
|
+
def __post_init__(self) -> None:
|
|
259
|
+
_require_release_facts(
|
|
260
|
+
store_kind=self.store_kind,
|
|
261
|
+
store_sha256=self.store_sha256,
|
|
262
|
+
predecessor_store_sha256=self.predecessor_store_sha256,
|
|
263
|
+
entry_count=self.entry_count,
|
|
264
|
+
published_at=self.published_at,
|
|
265
|
+
code="CATALOG_UPDATE_STATE",
|
|
266
|
+
path="installed",
|
|
267
|
+
)
|
|
268
|
+
if not is_digest(self.archive_sha256):
|
|
269
|
+
raise _refuse(
|
|
270
|
+
"CATALOG_UPDATE_STATE",
|
|
271
|
+
"installed.archive_sha256",
|
|
272
|
+
"must be one exact lowercase SHA-256",
|
|
273
|
+
)
|
|
274
|
+
prefix = f"{GENERATIONS_DIRNAME}/"
|
|
275
|
+
folder = (
|
|
276
|
+
self.generation_path[len(prefix) :]
|
|
277
|
+
if isinstance(self.generation_path, str) and self.generation_path.startswith(prefix)
|
|
278
|
+
else ""
|
|
279
|
+
)
|
|
280
|
+
if not _GENERATION_FOLDER_PATTERN.fullmatch(folder):
|
|
281
|
+
raise _refuse(
|
|
282
|
+
"CATALOG_UPDATE_STATE",
|
|
283
|
+
"installed.generation_path",
|
|
284
|
+
"must name one digest-named folder directly under the generations folder",
|
|
285
|
+
)
|
|
286
|
+
if not self.store_sha256.startswith(folder):
|
|
287
|
+
raise _refuse(
|
|
288
|
+
"CATALOG_UPDATE_STATE",
|
|
289
|
+
"installed.generation_path",
|
|
290
|
+
"must be named by the recorded store digest",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
@classmethod
|
|
294
|
+
def from_dict(cls, data: Any, *, path: str = "installed") -> InstalledState:
|
|
295
|
+
if not isinstance(data, dict) or set(data) != _INSTALLED_STATE_KEYS:
|
|
296
|
+
raise _refuse(
|
|
297
|
+
"CATALOG_UPDATE_STATE", path, "must carry exactly the recorded install fields"
|
|
298
|
+
)
|
|
299
|
+
if data["schema_version"] != INSTALLED_STATE_SCHEMA:
|
|
300
|
+
raise _refuse(
|
|
301
|
+
"CATALOG_UPDATE_STATE",
|
|
302
|
+
f"{path}.schema_version",
|
|
303
|
+
f"must be {INSTALLED_STATE_SCHEMA}",
|
|
304
|
+
)
|
|
305
|
+
return cls(
|
|
306
|
+
store_kind=data["store_kind"],
|
|
307
|
+
store_sha256=data["store_sha256"],
|
|
308
|
+
archive_sha256=data["archive_sha256"],
|
|
309
|
+
predecessor_store_sha256=data["predecessor_store_sha256"],
|
|
310
|
+
entry_count=data["entry_count"],
|
|
311
|
+
published_at=data["published_at"],
|
|
312
|
+
generation_path=data["generation_path"],
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def to_dict(self) -> dict[str, Any]:
|
|
316
|
+
return {
|
|
317
|
+
"schema_version": INSTALLED_STATE_SCHEMA,
|
|
318
|
+
"store_kind": self.store_kind,
|
|
319
|
+
"store_sha256": self.store_sha256,
|
|
320
|
+
"archive_sha256": self.archive_sha256,
|
|
321
|
+
"predecessor_store_sha256": self.predecessor_store_sha256,
|
|
322
|
+
"entry_count": self.entry_count,
|
|
323
|
+
"published_at": self.published_at,
|
|
324
|
+
"generation_path": self.generation_path,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass(frozen=True)
|
|
329
|
+
class CatalogUpdateReport:
|
|
330
|
+
"""The complete account of one install decision."""
|
|
331
|
+
|
|
332
|
+
status: str
|
|
333
|
+
state: InstalledState
|
|
334
|
+
catalog_root: str
|
|
335
|
+
|
|
336
|
+
def to_dict(self) -> dict[str, Any]:
|
|
337
|
+
generation_root = os.path.join(self.catalog_root, self.state.generation_path)
|
|
338
|
+
return {
|
|
339
|
+
"schema_version": "mr-data-catalog-update.v1",
|
|
340
|
+
"status": self.status,
|
|
341
|
+
"store_kind": self.state.store_kind,
|
|
342
|
+
"store_sha256": self.state.store_sha256,
|
|
343
|
+
"archive_sha256": self.state.archive_sha256,
|
|
344
|
+
"predecessor_store_sha256": self.state.predecessor_store_sha256,
|
|
345
|
+
"entry_count": self.state.entry_count,
|
|
346
|
+
"published_at": self.state.published_at,
|
|
347
|
+
"catalog_root": self.catalog_root,
|
|
348
|
+
"generation_root": generation_root,
|
|
349
|
+
"search": {
|
|
350
|
+
"public_index_root": generation_root,
|
|
351
|
+
"public_manifest_sha256": self.state.store_sha256,
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _screen_member_name(name: str) -> None:
|
|
357
|
+
"""Admit one archive member name, or refuse it outright. Folder members are refused."""
|
|
358
|
+
|
|
359
|
+
if "\\" in name:
|
|
360
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", name, "member names never contain a backslash")
|
|
361
|
+
if name.startswith("/") or "//" in name:
|
|
362
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", name, "member names are relative and clean")
|
|
363
|
+
if name.endswith("/"):
|
|
364
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", name, "folder members are not admitted")
|
|
365
|
+
parts = name.split("/")
|
|
366
|
+
if len(parts) > MAX_MEMBER_NAME_PARTS:
|
|
367
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", name, "member name has too many folders")
|
|
368
|
+
for part in parts:
|
|
369
|
+
if part in {"", ".", ".."}:
|
|
370
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", name, "member name has an empty or dot part")
|
|
371
|
+
if len(part) > MAX_MEMBER_NAME_PART_LENGTH:
|
|
372
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", name, "member name part is too long")
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _screen_archive(
|
|
376
|
+
archive: zipfile.ZipFile,
|
|
377
|
+
) -> tuple[zipfile.ZipInfo, tuple[zipfile.ZipInfo, ...]]:
|
|
378
|
+
"""Screen every member, returning the release descriptor member and the payload members."""
|
|
379
|
+
|
|
380
|
+
descriptor_member: zipfile.ZipInfo | None = None
|
|
381
|
+
payload: list[zipfile.ZipInfo] = []
|
|
382
|
+
# Duplicates are judged after case folding and accent normalisation because the destination
|
|
383
|
+
# filesystem may apply either: two names this set cannot tell apart cannot both land.
|
|
384
|
+
seen: set[str] = set()
|
|
385
|
+
total_bytes = 0
|
|
386
|
+
total_compressed = 0
|
|
387
|
+
for info in archive.infolist():
|
|
388
|
+
name = info.filename
|
|
389
|
+
_screen_member_name(name)
|
|
390
|
+
folded = unicodedata.normalize("NFC", name).casefold()
|
|
391
|
+
if folded in seen:
|
|
392
|
+
raise _refuse(
|
|
393
|
+
"CATALOG_UPDATE_ARCHIVE",
|
|
394
|
+
name,
|
|
395
|
+
"member name appears more than once, or collides with another on a filesystem "
|
|
396
|
+
"that folds case or accents",
|
|
397
|
+
)
|
|
398
|
+
seen.add(folded)
|
|
399
|
+
try:
|
|
400
|
+
screen_zip_entry(info, subject=name, described="release archive member")
|
|
401
|
+
except ReaderError as error:
|
|
402
|
+
raise _refuse(
|
|
403
|
+
"CATALOG_UPDATE_ARCHIVE", name, "member is not one plain admissible file"
|
|
404
|
+
) from error
|
|
405
|
+
if info.file_size > MAX_RELEASE_MEMBER_BYTES:
|
|
406
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", name, "member is larger than the bound")
|
|
407
|
+
total_bytes += info.file_size
|
|
408
|
+
total_compressed += info.compress_size
|
|
409
|
+
if name == RELEASE_DESCRIPTOR_FILENAME:
|
|
410
|
+
if info.file_size > MAX_RELEASE_DESCRIPTOR_BYTES:
|
|
411
|
+
raise _refuse(
|
|
412
|
+
"CATALOG_UPDATE_ARCHIVE", name, "release statement is larger than the bound"
|
|
413
|
+
)
|
|
414
|
+
descriptor_member = info
|
|
415
|
+
continue
|
|
416
|
+
if not name.startswith(ARCHIVE_MEMBER_PREFIX) or len(name) <= len(ARCHIVE_MEMBER_PREFIX):
|
|
417
|
+
raise _refuse(
|
|
418
|
+
"CATALOG_UPDATE_ARCHIVE",
|
|
419
|
+
name,
|
|
420
|
+
"every payload member lives under the catalog prefix",
|
|
421
|
+
)
|
|
422
|
+
payload.append(info)
|
|
423
|
+
if len(payload) > MAX_RELEASE_MEMBER_COUNT:
|
|
424
|
+
raise _refuse(
|
|
425
|
+
"CATALOG_UPDATE_ARCHIVE", "archive", "archive holds more members than the bound"
|
|
426
|
+
)
|
|
427
|
+
if total_bytes > MAX_RELEASE_ARCHIVE_BYTES:
|
|
428
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", "archive", "archive expands past the byte bound")
|
|
429
|
+
if total_bytes > max(ARCHIVE_EXPANSION_FLOOR_BYTES, total_compressed * ARCHIVE_EXPANSION_RATIO):
|
|
430
|
+
raise _refuse(
|
|
431
|
+
"CATALOG_UPDATE_ARCHIVE", "archive", "archive expands out of proportion to its size"
|
|
432
|
+
)
|
|
433
|
+
if descriptor_member is None:
|
|
434
|
+
raise _refuse(
|
|
435
|
+
"CATALOG_UPDATE_ARCHIVE", RELEASE_DESCRIPTOR_FILENAME, "release statement is missing"
|
|
436
|
+
)
|
|
437
|
+
if not payload:
|
|
438
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", "archive", "archive holds no catalog members")
|
|
439
|
+
return descriptor_member, tuple(payload)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _read_member(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes:
|
|
443
|
+
"""Read one screened member, holding it to its declared size exactly.
|
|
444
|
+
|
|
445
|
+
The archive digest covers the packed bytes, not the unpacking: a member whose stream is
|
|
446
|
+
corrupt, or whose checksum disagrees, is a typed refusal rather than a crash.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
try:
|
|
450
|
+
with archive.open(info) as handle:
|
|
451
|
+
return read_bounded(handle, declared=info.file_size, subject=info.filename)
|
|
452
|
+
except ReaderError as error:
|
|
453
|
+
raise _refuse(
|
|
454
|
+
"CATALOG_UPDATE_ARCHIVE", info.filename, "member bytes differ from the declared size"
|
|
455
|
+
) from error
|
|
456
|
+
except (zipfile.BadZipFile, zlib.error) as error:
|
|
457
|
+
raise _refuse(
|
|
458
|
+
"CATALOG_UPDATE_ARCHIVE", info.filename, "member bytes are corrupt inside the archive"
|
|
459
|
+
) from error
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def verify_release_archive(
|
|
463
|
+
raw: bytes, *, expected_archive_sha256: str
|
|
464
|
+
) -> tuple[ReleaseDescriptor, zipfile.ZipFile, tuple[zipfile.ZipInfo, ...]]:
|
|
465
|
+
"""Verify one release archive down to its store file digest, without touching a disk."""
|
|
466
|
+
|
|
467
|
+
if not isinstance(raw, bytes):
|
|
468
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", "archive", "archive must be bytes")
|
|
469
|
+
if len(raw) > MAX_RELEASE_ARCHIVE_BYTES:
|
|
470
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", "archive", "archive is larger than the bound")
|
|
471
|
+
if not is_digest(expected_archive_sha256):
|
|
472
|
+
raise _refuse(
|
|
473
|
+
"CATALOG_UPDATE_DIGEST_SPELLING",
|
|
474
|
+
"expected_archive_sha256",
|
|
475
|
+
"must be exactly 64 lowercase hex characters",
|
|
476
|
+
)
|
|
477
|
+
if sha256_bytes(raw) != expected_archive_sha256:
|
|
478
|
+
raise _refuse(
|
|
479
|
+
"CATALOG_UPDATE_DIGEST",
|
|
480
|
+
"archive",
|
|
481
|
+
"archive bytes do not match the published SHA-256",
|
|
482
|
+
)
|
|
483
|
+
try:
|
|
484
|
+
archive = zipfile.ZipFile(io.BytesIO(raw))
|
|
485
|
+
except (zipfile.BadZipFile, ValueError) as error:
|
|
486
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", "archive", "archive is not a readable zip") from (
|
|
487
|
+
error
|
|
488
|
+
)
|
|
489
|
+
descriptor_member, payload = _screen_archive(archive)
|
|
490
|
+
try:
|
|
491
|
+
descriptor_data = parse_canonical_json(_read_member(archive, descriptor_member))
|
|
492
|
+
except CanonicalJSONError as error:
|
|
493
|
+
raise _refuse(
|
|
494
|
+
"CATALOG_UPDATE_RELEASE",
|
|
495
|
+
RELEASE_DESCRIPTOR_FILENAME,
|
|
496
|
+
"release statement is not canonical JSON",
|
|
497
|
+
) from error
|
|
498
|
+
descriptor = ReleaseDescriptor.from_dict(descriptor_data)
|
|
499
|
+
store_name = ARCHIVE_MEMBER_PREFIX + _STORE_FILENAMES[descriptor.store_kind]
|
|
500
|
+
store_member = next((info for info in payload if info.filename == store_name), None)
|
|
501
|
+
if store_member is None:
|
|
502
|
+
raise _refuse(
|
|
503
|
+
"CATALOG_UPDATE_RELEASE", store_name, "the named store file is not in the archive"
|
|
504
|
+
)
|
|
505
|
+
if store_member.file_size > _STORE_FILE_MAXIMUMS[descriptor.store_kind]:
|
|
506
|
+
raise _refuse("CATALOG_UPDATE_RELEASE", store_name, "store file is larger than its bound")
|
|
507
|
+
store_raw = _read_member(archive, store_member)
|
|
508
|
+
if sha256_bytes(store_raw) != descriptor.store_sha256:
|
|
509
|
+
raise _refuse(
|
|
510
|
+
"CATALOG_UPDATE_RELEASE",
|
|
511
|
+
store_name,
|
|
512
|
+
"store file bytes do not match the release statement",
|
|
513
|
+
)
|
|
514
|
+
if descriptor.store_kind == "packed":
|
|
515
|
+
try:
|
|
516
|
+
head = parse_canonical_json(store_raw)
|
|
517
|
+
except CanonicalJSONError as error:
|
|
518
|
+
raise _refuse(
|
|
519
|
+
"CATALOG_UPDATE_RELEASE", store_name, "store file is not canonical JSON"
|
|
520
|
+
) from error
|
|
521
|
+
head_predecessor = head.get("predecessor_head_sha256") if isinstance(head, dict) else None
|
|
522
|
+
if head_predecessor != descriptor.predecessor_store_sha256:
|
|
523
|
+
raise _refuse(
|
|
524
|
+
"CATALOG_UPDATE_RELEASE",
|
|
525
|
+
store_name,
|
|
526
|
+
"release statement disagrees with the store file about the predecessor",
|
|
527
|
+
)
|
|
528
|
+
return descriptor, archive, payload
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def load_installed_state(root: Path) -> tuple[InstalledState, bytes] | None:
|
|
532
|
+
"""Read the recorded install state under ``root``, or None when nothing is recorded."""
|
|
533
|
+
|
|
534
|
+
if not isinstance(root, Path):
|
|
535
|
+
raise _refuse("CATALOG_UPDATE_STATE", "root", "must be a pathlib.Path")
|
|
536
|
+
state_path = root / INSTALLED_STATE_FILENAME
|
|
537
|
+
if not os.path.lexists(state_path):
|
|
538
|
+
return None
|
|
539
|
+
try:
|
|
540
|
+
raw = read_bounded_document(
|
|
541
|
+
state_path, maximum=MAX_INSTALLED_STATE_BYTES, label="catalog update state"
|
|
542
|
+
)
|
|
543
|
+
except SourceContractError as error:
|
|
544
|
+
raise _refuse(
|
|
545
|
+
"CATALOG_UPDATE_STATE",
|
|
546
|
+
str(state_path),
|
|
547
|
+
"recorded install state could not be read safely",
|
|
548
|
+
) from error
|
|
549
|
+
try:
|
|
550
|
+
data = parse_canonical_json(raw)
|
|
551
|
+
except CanonicalJSONError as error:
|
|
552
|
+
raise _refuse(
|
|
553
|
+
"CATALOG_UPDATE_STATE", str(state_path), "recorded install state is not canonical"
|
|
554
|
+
) from error
|
|
555
|
+
return InstalledState.from_dict(data), raw
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _materialize(
|
|
559
|
+
archive: zipfile.ZipFile,
|
|
560
|
+
payload: tuple[zipfile.ZipInfo, ...],
|
|
561
|
+
*,
|
|
562
|
+
staging: Path,
|
|
563
|
+
) -> None:
|
|
564
|
+
"""Write the payload under the private staging folder, durably, without publishing it."""
|
|
565
|
+
|
|
566
|
+
os.makedirs(staging, mode=0o755)
|
|
567
|
+
try:
|
|
568
|
+
for info in sorted(payload, key=lambda member: member.filename):
|
|
569
|
+
relative = info.filename[len(ARCHIVE_MEMBER_PREFIX) :]
|
|
570
|
+
target = staging.joinpath(*relative.split("/"))
|
|
571
|
+
os.makedirs(target.parent, mode=0o755, exist_ok=True)
|
|
572
|
+
data = _read_member(archive, info)
|
|
573
|
+
descriptor = os.open(
|
|
574
|
+
target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o644
|
|
575
|
+
)
|
|
576
|
+
try:
|
|
577
|
+
view = memoryview(data)
|
|
578
|
+
while view:
|
|
579
|
+
written = os.write(descriptor, view)
|
|
580
|
+
if written <= 0:
|
|
581
|
+
raise _refuse(
|
|
582
|
+
"CATALOG_UPDATE_INSTALL",
|
|
583
|
+
str(target),
|
|
584
|
+
"the destination stopped accepting bytes",
|
|
585
|
+
)
|
|
586
|
+
view = view[written:]
|
|
587
|
+
os.fsync(descriptor)
|
|
588
|
+
finally:
|
|
589
|
+
os.close(descriptor)
|
|
590
|
+
for current, _directories, _files in os.walk(staging):
|
|
591
|
+
dir_fd = os.open(current, os.O_RDONLY)
|
|
592
|
+
try:
|
|
593
|
+
os.fsync(dir_fd)
|
|
594
|
+
finally:
|
|
595
|
+
os.close(dir_fd)
|
|
596
|
+
except OSError as error:
|
|
597
|
+
raise _refuse(
|
|
598
|
+
"CATALOG_UPDATE_INSTALL", str(staging), "the generation could not be written out"
|
|
599
|
+
) from error
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _publish(staging: Path, destination: Path, generations_root: Path) -> None:
|
|
603
|
+
"""Make one fully verified staging folder visible under its final name, atomically."""
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
os.rename(staging, destination)
|
|
607
|
+
root_fd = os.open(generations_root, os.O_RDONLY)
|
|
608
|
+
try:
|
|
609
|
+
os.fsync(root_fd)
|
|
610
|
+
finally:
|
|
611
|
+
os.close(root_fd)
|
|
612
|
+
except OSError as error:
|
|
613
|
+
raise _refuse(
|
|
614
|
+
"CATALOG_UPDATE_INSTALL", str(destination), "the generation could not be published"
|
|
615
|
+
) from error
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _verify_installed_store(
|
|
619
|
+
root: Path, descriptor: ReleaseDescriptor, *, neural_backend: Any = None
|
|
620
|
+
) -> None:
|
|
621
|
+
"""Run the real store verification over the installed folder, exactly as a search would."""
|
|
622
|
+
|
|
623
|
+
kind = detect_public_store_kind(root)
|
|
624
|
+
if kind != descriptor.store_kind:
|
|
625
|
+
raise _refuse(
|
|
626
|
+
"CATALOG_UPDATE_RELEASE",
|
|
627
|
+
str(root),
|
|
628
|
+
"installed folder is not the store kind the release names",
|
|
629
|
+
)
|
|
630
|
+
if descriptor.store_kind == "packed":
|
|
631
|
+
verified = verify_packed_generation(root, expected_head_sha256=descriptor.store_sha256)
|
|
632
|
+
installed_count = verified.entry_count
|
|
633
|
+
else:
|
|
634
|
+
loaded = load_retrieval_catalog(
|
|
635
|
+
root,
|
|
636
|
+
expected_manifest_sha256=descriptor.store_sha256,
|
|
637
|
+
neural_backend=neural_backend,
|
|
638
|
+
)
|
|
639
|
+
installed_count = len(loaded.lexical.entries)
|
|
640
|
+
if installed_count != descriptor.entry_count:
|
|
641
|
+
raise _refuse(
|
|
642
|
+
"CATALOG_UPDATE_RELEASE",
|
|
643
|
+
str(root),
|
|
644
|
+
"installed entry count differs from the release statement",
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def confirm_already_current(
|
|
649
|
+
root: Path, *, expected_archive_sha256: str, neural_backend: Any = None
|
|
650
|
+
) -> CatalogUpdateReport | None:
|
|
651
|
+
"""Answer whether the release named by this digest is already installed, without the archive.
|
|
652
|
+
|
|
653
|
+
The recorded state binds the archive digest it was installed from, so equality of the typed
|
|
654
|
+
digest is enough to know no bytes need to move; the installed folder is still re-verified
|
|
655
|
+
end to end before "already-current" is claimed.
|
|
656
|
+
"""
|
|
657
|
+
|
|
658
|
+
if not is_digest(expected_archive_sha256):
|
|
659
|
+
raise _refuse(
|
|
660
|
+
"CATALOG_UPDATE_DIGEST_SPELLING",
|
|
661
|
+
"expected_archive_sha256",
|
|
662
|
+
"must be exactly 64 lowercase hex characters",
|
|
663
|
+
)
|
|
664
|
+
recorded = load_installed_state(root)
|
|
665
|
+
if recorded is None:
|
|
666
|
+
return None
|
|
667
|
+
state, _state_raw = recorded
|
|
668
|
+
if state.archive_sha256 != expected_archive_sha256:
|
|
669
|
+
return None
|
|
670
|
+
_verify_installed_store(
|
|
671
|
+
root / state.generation_path,
|
|
672
|
+
ReleaseDescriptor(
|
|
673
|
+
store_kind=state.store_kind,
|
|
674
|
+
store_sha256=state.store_sha256,
|
|
675
|
+
predecessor_store_sha256=state.predecessor_store_sha256,
|
|
676
|
+
entry_count=state.entry_count,
|
|
677
|
+
published_at=state.published_at,
|
|
678
|
+
),
|
|
679
|
+
neural_backend=neural_backend,
|
|
680
|
+
)
|
|
681
|
+
return CatalogUpdateReport(status="already-current", state=state, catalog_root=str(root))
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def install_release(
|
|
685
|
+
root: Path,
|
|
686
|
+
raw: bytes,
|
|
687
|
+
*,
|
|
688
|
+
expected_archive_sha256: str,
|
|
689
|
+
replace: bool = False,
|
|
690
|
+
neural_backend: Any = None,
|
|
691
|
+
) -> CatalogUpdateReport:
|
|
692
|
+
"""Verify one release archive and install it under ``root``, honouring the recorded chain."""
|
|
693
|
+
|
|
694
|
+
if not isinstance(root, Path):
|
|
695
|
+
raise _refuse("CATALOG_UPDATE_INSTALL", "root", "must be a pathlib.Path")
|
|
696
|
+
recorded = load_installed_state(root)
|
|
697
|
+
descriptor, archive, payload = verify_release_archive(
|
|
698
|
+
raw, expected_archive_sha256=expected_archive_sha256
|
|
699
|
+
)
|
|
700
|
+
if recorded is not None:
|
|
701
|
+
state, _state_raw = recorded
|
|
702
|
+
if state.store_sha256 == descriptor.store_sha256:
|
|
703
|
+
_verify_installed_store(
|
|
704
|
+
root / state.generation_path, descriptor, neural_backend=neural_backend
|
|
705
|
+
)
|
|
706
|
+
return CatalogUpdateReport(
|
|
707
|
+
status="already-current", state=state, catalog_root=str(root)
|
|
708
|
+
)
|
|
709
|
+
if not replace and descriptor.predecessor_store_sha256 != state.store_sha256:
|
|
710
|
+
raise _refuse(
|
|
711
|
+
"CATALOG_UPDATE_CHAIN",
|
|
712
|
+
str(root / INSTALLED_STATE_FILENAME),
|
|
713
|
+
"release does not descend from the installed generation",
|
|
714
|
+
)
|
|
715
|
+
generation_name = descriptor.store_sha256[:12]
|
|
716
|
+
generations_root = root / GENERATIONS_DIRNAME
|
|
717
|
+
os.makedirs(generations_root, exist_ok=True)
|
|
718
|
+
destination = generations_root / generation_name
|
|
719
|
+
if os.path.lexists(destination):
|
|
720
|
+
# The append-only design retains every generation ever installed, so the folder may be
|
|
721
|
+
# a complete earlier install of this same release -- re-verify and adopt it. Anything
|
|
722
|
+
# else wearing this name is refused, never overwritten.
|
|
723
|
+
try:
|
|
724
|
+
_verify_installed_store(destination, descriptor, neural_backend=neural_backend)
|
|
725
|
+
except SourceContractError as error:
|
|
726
|
+
raise _refuse(
|
|
727
|
+
"CATALOG_UPDATE_INSTALL",
|
|
728
|
+
str(destination),
|
|
729
|
+
"a folder with this generation's name is present but is not this release; "
|
|
730
|
+
"remove it and run the update again",
|
|
731
|
+
) from error
|
|
732
|
+
else:
|
|
733
|
+
staging = generations_root / f".staging-{generation_name}"
|
|
734
|
+
if os.path.lexists(staging):
|
|
735
|
+
raise _refuse(
|
|
736
|
+
"CATALOG_UPDATE_INSTALL",
|
|
737
|
+
str(staging),
|
|
738
|
+
"an unfinished earlier install is in the way; remove it and run the update again",
|
|
739
|
+
)
|
|
740
|
+
_materialize(archive, payload, staging=staging)
|
|
741
|
+
archive.close()
|
|
742
|
+
_verify_installed_store(staging, descriptor, neural_backend=neural_backend)
|
|
743
|
+
_publish(staging, destination, generations_root)
|
|
744
|
+
new_state = InstalledState(
|
|
745
|
+
store_kind=descriptor.store_kind,
|
|
746
|
+
store_sha256=descriptor.store_sha256,
|
|
747
|
+
archive_sha256=expected_archive_sha256,
|
|
748
|
+
predecessor_store_sha256=descriptor.predecessor_store_sha256,
|
|
749
|
+
entry_count=descriptor.entry_count,
|
|
750
|
+
published_at=descriptor.published_at,
|
|
751
|
+
generation_path=f"{GENERATIONS_DIRNAME}/{generation_name}",
|
|
752
|
+
)
|
|
753
|
+
replaces: Mapping[str, Any] | None = None
|
|
754
|
+
if recorded is not None:
|
|
755
|
+
replaces = {"receipt_sha256": sha256_bytes(recorded[1])}
|
|
756
|
+
install_receipt(
|
|
757
|
+
root / INSTALLED_STATE_FILENAME,
|
|
758
|
+
new_state.to_dict(),
|
|
759
|
+
maximum=MAX_INSTALLED_STATE_BYTES,
|
|
760
|
+
label="catalog update state",
|
|
761
|
+
code_prefix="CATALOG_UPDATE",
|
|
762
|
+
refusal=SourceContractError,
|
|
763
|
+
replaces=replaces,
|
|
764
|
+
)
|
|
765
|
+
return CatalogUpdateReport(status="installed", state=new_state, catalog_root=str(root))
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _collect_generation_files(generation_root: Path) -> tuple[tuple[str, Path], ...]:
|
|
769
|
+
"""Walk one generation folder, admitting only plain files reached without links.
|
|
770
|
+
|
|
771
|
+
Names are screened exactly as the installer will screen them -- with the archive prefix on --
|
|
772
|
+
so nothing packages here that an installer would refuse there, and the byte bounds are
|
|
773
|
+
enforced from sizes before any file is read.
|
|
774
|
+
"""
|
|
775
|
+
|
|
776
|
+
collected: list[tuple[str, Path]] = []
|
|
777
|
+
total_bytes = 0
|
|
778
|
+
for current, directories, files in os.walk(generation_root, followlinks=False):
|
|
779
|
+
for directory in directories:
|
|
780
|
+
if stat.S_ISLNK(os.lstat(Path(current) / directory).st_mode):
|
|
781
|
+
raise _refuse(
|
|
782
|
+
"CATALOG_UPDATE_ARCHIVE",
|
|
783
|
+
str(Path(current) / directory),
|
|
784
|
+
"a folder inside the generation may not be a symbolic link",
|
|
785
|
+
)
|
|
786
|
+
directories.sort()
|
|
787
|
+
for name in sorted(files):
|
|
788
|
+
full = Path(current) / name
|
|
789
|
+
probe = os.lstat(full)
|
|
790
|
+
if not stat.S_ISREG(probe.st_mode):
|
|
791
|
+
raise _refuse(
|
|
792
|
+
"CATALOG_UPDATE_ARCHIVE", str(full), "only plain files can be released"
|
|
793
|
+
)
|
|
794
|
+
if probe.st_size > MAX_RELEASE_MEMBER_BYTES:
|
|
795
|
+
raise _refuse(
|
|
796
|
+
"CATALOG_UPDATE_ARCHIVE", str(full), "member is larger than the bound"
|
|
797
|
+
)
|
|
798
|
+
total_bytes += probe.st_size
|
|
799
|
+
relative = full.relative_to(generation_root).as_posix()
|
|
800
|
+
_screen_member_name(ARCHIVE_MEMBER_PREFIX + relative)
|
|
801
|
+
collected.append((relative, full))
|
|
802
|
+
if not collected:
|
|
803
|
+
raise _refuse(
|
|
804
|
+
"CATALOG_UPDATE_ARCHIVE", str(generation_root), "generation folder holds no files"
|
|
805
|
+
)
|
|
806
|
+
if len(collected) > MAX_RELEASE_MEMBER_COUNT:
|
|
807
|
+
raise _refuse(
|
|
808
|
+
"CATALOG_UPDATE_ARCHIVE",
|
|
809
|
+
str(generation_root),
|
|
810
|
+
"generation folder holds more files than the bound",
|
|
811
|
+
)
|
|
812
|
+
if total_bytes > MAX_RELEASE_ARCHIVE_BYTES:
|
|
813
|
+
raise _refuse(
|
|
814
|
+
"CATALOG_UPDATE_ARCHIVE",
|
|
815
|
+
str(generation_root),
|
|
816
|
+
"generation folder is larger than the release bound",
|
|
817
|
+
)
|
|
818
|
+
return tuple(sorted(collected))
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _write_archive_member(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
|
|
822
|
+
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
|
823
|
+
info.create_system = 3
|
|
824
|
+
info.external_attr = (stat.S_IFREG | 0o644) << 16
|
|
825
|
+
info.compress_type = zipfile.ZIP_STORED
|
|
826
|
+
archive.writestr(info, data)
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def build_release_archive(
|
|
830
|
+
generation_root: Path,
|
|
831
|
+
*,
|
|
832
|
+
predecessor_store_sha256: str | None,
|
|
833
|
+
published_at: str,
|
|
834
|
+
neural_backend: Any = None,
|
|
835
|
+
) -> tuple[bytes, ReleaseDescriptor]:
|
|
836
|
+
"""Package one verified generation folder into a byte-reproducible release archive.
|
|
837
|
+
|
|
838
|
+
Every member is stored uncompressed with fixed metadata, so the archive's SHA-256 is a pure
|
|
839
|
+
function of the generation's bytes: the same generation packaged on any machine yields the
|
|
840
|
+
same published digest.
|
|
841
|
+
"""
|
|
842
|
+
|
|
843
|
+
kind = detect_public_store_kind(generation_root)
|
|
844
|
+
if kind not in SUPPORTED_STORE_KINDS:
|
|
845
|
+
raise _refuse(
|
|
846
|
+
"CATALOG_UPDATE_STORE", str(generation_root), "must name a supported store kind"
|
|
847
|
+
)
|
|
848
|
+
store_raw = read_bounded_document(
|
|
849
|
+
generation_root / _STORE_FILENAMES[kind],
|
|
850
|
+
maximum=_STORE_FILE_MAXIMUMS[kind],
|
|
851
|
+
label="release store file",
|
|
852
|
+
)
|
|
853
|
+
store_sha256 = sha256_bytes(store_raw)
|
|
854
|
+
if kind == "packed":
|
|
855
|
+
verified = verify_packed_generation(generation_root, expected_head_sha256=store_sha256)
|
|
856
|
+
entry_count = verified.entry_count
|
|
857
|
+
head_predecessor = verified.head.get("predecessor_head_sha256")
|
|
858
|
+
if head_predecessor != predecessor_store_sha256:
|
|
859
|
+
raise _refuse(
|
|
860
|
+
"CATALOG_UPDATE_RELEASE",
|
|
861
|
+
str(generation_root),
|
|
862
|
+
"stated predecessor disagrees with the store file",
|
|
863
|
+
)
|
|
864
|
+
else:
|
|
865
|
+
loaded = load_retrieval_catalog(
|
|
866
|
+
generation_root,
|
|
867
|
+
expected_manifest_sha256=store_sha256,
|
|
868
|
+
neural_backend=neural_backend,
|
|
869
|
+
)
|
|
870
|
+
entry_count = len(loaded.lexical.entries)
|
|
871
|
+
descriptor = ReleaseDescriptor(
|
|
872
|
+
store_kind=kind,
|
|
873
|
+
store_sha256=store_sha256,
|
|
874
|
+
predecessor_store_sha256=predecessor_store_sha256,
|
|
875
|
+
entry_count=entry_count,
|
|
876
|
+
published_at=published_at,
|
|
877
|
+
)
|
|
878
|
+
files = _collect_generation_files(generation_root)
|
|
879
|
+
buffer = io.BytesIO()
|
|
880
|
+
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_STORED) as archive:
|
|
881
|
+
# "catalog/..." sorts before "release.json", and the constant prefix preserves the
|
|
882
|
+
# files' own order, so writing files first then the statement is the sorted order.
|
|
883
|
+
for relative, full in files:
|
|
884
|
+
_write_archive_member(archive, ARCHIVE_MEMBER_PREFIX + relative, full.read_bytes())
|
|
885
|
+
_write_archive_member(
|
|
886
|
+
archive, RELEASE_DESCRIPTOR_FILENAME, canonical_json_bytes(descriptor.to_dict())
|
|
887
|
+
)
|
|
888
|
+
raw = buffer.getvalue()
|
|
889
|
+
if len(raw) > MAX_RELEASE_ARCHIVE_BYTES:
|
|
890
|
+
raise _refuse("CATALOG_UPDATE_ARCHIVE", "archive", "archive is larger than the bound")
|
|
891
|
+
return raw, descriptor
|