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,2152 @@
|
|
|
1
|
+
"""Append-only run events for live status views.
|
|
2
|
+
|
|
3
|
+
The feed is a derived projection, not durable authority, and is excluded from sealed digests. Event
|
|
4
|
+
pointers use candidate-relative paths and SHA-256 digests. The five-field record shape is fixed;
|
|
5
|
+
new event names are added to the closed name sets and fact specification in this module.
|
|
6
|
+
|
|
7
|
+
The vocabulary is extensible by NAME and never by FIELD, so a new fact means a new name. Build
|
|
8
|
+
events cost three things in one commit: an `EVENT_FACTS` entry, a live emit site in `pipeline.py`,
|
|
9
|
+
and a matching branch in `project_sealed_run` that derives the identical record from sealed bytes
|
|
10
|
+
alone. The third is the binding one -- a live BUILD event a projection cannot re-derive is
|
|
11
|
+
forbidden, which is why a time-throttled heartbeat is not in this vocabulary and is not to be
|
|
12
|
+
added to it.
|
|
13
|
+
|
|
14
|
+
The one explicit exception is the bounded post-seal notebook-sidecar sequence. It narrates an
|
|
15
|
+
unsealed convenience write performed by the CLI after the Build exists, carries no path, digest,
|
|
16
|
+
or authority, and cannot be projected from sealed bytes. Its three names are separated below so a
|
|
17
|
+
consumer can never mistake sidecar state for Build evidence. Feed length remains bounded by the
|
|
18
|
+
shape of one Build plus that fixed sequence; per-byte, per-row and per-chunk emission are refused.
|
|
19
|
+
|
|
20
|
+
Live hosted progress is NOT here and is not to be moved here. `progress_events` carries a second,
|
|
21
|
+
avowedly unsealed vocabulary for the hosted timeline; it is disjoint from every name set in this
|
|
22
|
+
module by construction, `project_sealed_run` has no branch for any of its names, and it reads this
|
|
23
|
+
feed only through the `observer` seam below, which adds no name and no record shape. The two
|
|
24
|
+
vocabularies never merge: a progress record proves nothing, and a Build record must always prove
|
|
25
|
+
itself from sealed bytes.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import contextlib
|
|
31
|
+
import datetime
|
|
32
|
+
import errno
|
|
33
|
+
import hashlib
|
|
34
|
+
import heapq
|
|
35
|
+
import json
|
|
36
|
+
import math
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import socket
|
|
40
|
+
import stat
|
|
41
|
+
import threading
|
|
42
|
+
import time
|
|
43
|
+
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
44
|
+
from contextvars import ContextVar
|
|
45
|
+
from pathlib import Path, PurePath
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
FEED_SCHEMA_VERSION = "mr-run-events.v2"
|
|
49
|
+
FEED_DIR_NAME = ".mr-events"
|
|
50
|
+
|
|
51
|
+
# Untrusted-input ceilings. A feed may have been produced by another local user or hand-edited, so
|
|
52
|
+
# the reader refuses rather than allocates.
|
|
53
|
+
MAX_FEED_BYTES = 4 * 1024 * 1024
|
|
54
|
+
MAX_FEED_RECORDS = 20_000
|
|
55
|
+
MAX_TEXT_CHARS = 512
|
|
56
|
+
MAX_FACT_KEYS = 24
|
|
57
|
+
|
|
58
|
+
# Bound the aggregate attempt set as well as each file. Selection keeps the newest attempts. If a
|
|
59
|
+
# matching attempt is outside the retained set, callers fall back to sealed receipts.
|
|
60
|
+
MAX_FEED_ATTEMPTS = 32
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _empty_attempt_size(name: str) -> int:
|
|
64
|
+
"""Exact byte size of the header-only attempt ``EventWriter`` creates for ``name``."""
|
|
65
|
+
|
|
66
|
+
header = {
|
|
67
|
+
"v": 1,
|
|
68
|
+
"kind": "header",
|
|
69
|
+
"schema_version": FEED_SCHEMA_VERSION,
|
|
70
|
+
"run": Path(name).stem,
|
|
71
|
+
}
|
|
72
|
+
return len((_dumps(header) + "\n").encode("utf-8"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# Valid event times fit Python's UTC datetime range. The bound also rejects non-finite values and
|
|
76
|
+
# finite numbers that cannot represent supported timestamps.
|
|
77
|
+
_EARLIEST_MOMENT = datetime.datetime.min.replace(tzinfo=datetime.UTC).timestamp()
|
|
78
|
+
_LATEST_MOMENT = datetime.datetime.max.replace(tzinfo=datetime.UTC).timestamp()
|
|
79
|
+
|
|
80
|
+
# Write-side ceilings on the shape of a fact value.
|
|
81
|
+
_MAX_FACT_DEPTH = 6
|
|
82
|
+
_MAX_LIST_ITEMS = 256
|
|
83
|
+
_TRUNCATION_MARKER = "..."
|
|
84
|
+
|
|
85
|
+
_O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
|
86
|
+
_O_NONBLOCK = getattr(os, "O_NONBLOCK", 0)
|
|
87
|
+
_O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
|
|
88
|
+
|
|
89
|
+
# Extend the vocabulary by adding names, not record fields. Local and hosted Build producers share
|
|
90
|
+
# the same record shape.
|
|
91
|
+
BUILD_EVENT_NAMES = frozenset(
|
|
92
|
+
{
|
|
93
|
+
"run_started",
|
|
94
|
+
"rights_checked",
|
|
95
|
+
"sources_read_started",
|
|
96
|
+
"source_read",
|
|
97
|
+
"sources_parsed_started",
|
|
98
|
+
"source_parsed",
|
|
99
|
+
"evidence_matched",
|
|
100
|
+
"cleaning_started",
|
|
101
|
+
"cleaning_applied",
|
|
102
|
+
"sources_profiled",
|
|
103
|
+
"join_completed",
|
|
104
|
+
"rows_selected",
|
|
105
|
+
"checks_started",
|
|
106
|
+
"check_completed",
|
|
107
|
+
"checks_completed",
|
|
108
|
+
"table_written",
|
|
109
|
+
"members_sealed_started",
|
|
110
|
+
"member_sealed",
|
|
111
|
+
"candidate_installed",
|
|
112
|
+
"snapshot_verify_started",
|
|
113
|
+
"member_verified",
|
|
114
|
+
"snapshot_verified",
|
|
115
|
+
"build_sealed",
|
|
116
|
+
"build_failed",
|
|
117
|
+
"run_interrupted",
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# CLI-only narration of the unsealed ``table.ipynb`` convenience write. These records are
|
|
122
|
+
# intentionally not in ``project_sealed_run``: sealed bytes cannot prove that a sibling file was
|
|
123
|
+
# attempted or written. They carry no location or digest and grant no handoff authority.
|
|
124
|
+
NOTEBOOK_SIDECAR_EVENT_NAMES = frozenset(
|
|
125
|
+
{
|
|
126
|
+
"notebook_sidecar_started",
|
|
127
|
+
"notebook_sidecar_ready",
|
|
128
|
+
"notebook_sidecar_failed",
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
NOTEBOOK_SIDECAR_OUTCOME_EVENT_NAMES = NOTEBOOK_SIDECAR_EVENT_NAMES - {"notebook_sidecar_started"}
|
|
132
|
+
|
|
133
|
+
EVENT_NAMES = BUILD_EVENT_NAMES | NOTEBOOK_SIDECAR_EVENT_NAMES
|
|
134
|
+
|
|
135
|
+
# The manifest version a recipe run seals under, spelled here as a LITERAL rather than imported.
|
|
136
|
+
# `project_sealed_run` is pure and imports nothing from `pipeline` -- a module-level import would
|
|
137
|
+
# close a cycle, and a deferred one inside the projector would give the projection a harness
|
|
138
|
+
# dependency it does not otherwise have. `evidence_matched` needs this value to gate on, because a
|
|
139
|
+
# recipe candidate seals `evidence/package.json` and still never matches proposal evidence. A test
|
|
140
|
+
# asserts this equals `pipeline.RECIPE_MANIFEST_VERSION` so the two spellings cannot drift.
|
|
141
|
+
_RECIPE_MANIFEST_VERSION = "candidate-manifest.v9"
|
|
142
|
+
|
|
143
|
+
# Terminal events for attempts that did not seal a Build.
|
|
144
|
+
TERMINAL_EVENT_NAMES = frozenset({"build_failed", "run_interrupted"})
|
|
145
|
+
|
|
146
|
+
# Reserved Courier and Reader event names have bounded but otherwise unenforced fact mappings.
|
|
147
|
+
# These events may
|
|
148
|
+
# carry a scheme-qualified remote locator (`https://`, `s3://`, `gs://`) in a fact value. See
|
|
149
|
+
# `_require_placeless`: that is placeless, because it resolves to the same bytes from every machine.
|
|
150
|
+
# A path on this disk, a `file://` URL and a loopback authority remain refused.
|
|
151
|
+
RESERVED_EVENT_NAMES = frozenset(
|
|
152
|
+
{
|
|
153
|
+
"fetch_started",
|
|
154
|
+
"slice_received",
|
|
155
|
+
"reader_opened",
|
|
156
|
+
"messages_decoded",
|
|
157
|
+
"cycle_locked",
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# This mapping is the authoritative declaration of what each event asserts. Two producers write
|
|
162
|
+
# Build records -- the live emitter in `pipeline.py` and the post-hoc projector
|
|
163
|
+
# `project_sealed_run` -- and a test asserts their Build prefixes agree. The CLI alone writes the
|
|
164
|
+
# three unsealed sidecar names. No producer may add, drop or rename a fact key; all read this table.
|
|
165
|
+
# A fact set change is a change HERE, and every applicable producer plus its tests moves together.
|
|
166
|
+
# `validate_facts` runs inside `EventWriter.write`, so a producer that drifts raises rather than
|
|
167
|
+
# writing a record that quietly contradicts the declared shape.
|
|
168
|
+
#
|
|
169
|
+
# Three entries need explicit constraints:
|
|
170
|
+
#
|
|
171
|
+
# * `checks_completed` has no `failing` key. `_validate_output` RAISES on the first failed check, so
|
|
172
|
+
# a `checks_completed` record can only ever exist for a run where every check passed; a `failing`
|
|
173
|
+
# key would be a list that is structurally always empty, which reads as a claim the data cannot
|
|
174
|
+
# make. A failed check is narrated by `build_failed`.
|
|
175
|
+
# * `source_read` has no `sha256` key. The live emitter has the raw bytes in hand but must not hash
|
|
176
|
+
# them -- the seal hashes every source already, and a second hash on the read path is work a
|
|
177
|
+
# event feed does not need. The digest is available through the record's
|
|
178
|
+
# `evidence_member` is `raw/<source_id>.csv`, which IS a sealed member, so its `member_sealed`
|
|
179
|
+
# record carries the digest.
|
|
180
|
+
# * `join_completed` requires all seven join keys with no omissions. `_join` always emits every one
|
|
181
|
+
# of them, and a plan always carries exactly one join over at least two sources, so there is no
|
|
182
|
+
# shape where a key is absent. Do not write a "skip if missing" branch.
|
|
183
|
+
# * `check_completed` has no `passed`, `observed` or `expected` key, on the exact precedent
|
|
184
|
+
# `checks_completed` sets immediately above: `_validate_output` raises on the first failed check,
|
|
185
|
+
# so those three would be structurally constant on every record that survives to be read. The
|
|
186
|
+
# record IS emitted before the refusal, so a failing check is still counted; the `build_failed`
|
|
187
|
+
# that follows carries that check's id as its `finding_id`, which is where a failure is narrated.
|
|
188
|
+
# * The six `*_started` markers each carry a `total` and nothing else. The admission test for one is
|
|
189
|
+
# that its denominator is fixed BEFORE its loop starts and is readable out of a sealed member
|
|
190
|
+
# afterwards -- a stage whose size is only knowable once it is finished is not progress, it is a
|
|
191
|
+
# spinner with a number on it. All six anchor `plan.json`, because the plan is what fixes every
|
|
192
|
+
# one of those totals: the source count and the cleaning-step count are literally in it, the check
|
|
193
|
+
# total is `2 + len(grain) + len(quality.not_null)`, and the member set is the static file set for
|
|
194
|
+
# the manifest version plus one `raw/<source_id>.csv` per planned source.
|
|
195
|
+
# * `member_verified` mirrors `member_sealed` exactly, for the same reason: its pointer varies per
|
|
196
|
+
# member and rides in `evidence` rather than in a fact.
|
|
197
|
+
EVENT_FACTS: Mapping[str, tuple[str, ...]] = {
|
|
198
|
+
"run_started": ("sources", "output_intent", "grain", "columns", "evidence_member"),
|
|
199
|
+
"rights_checked": ("sources", "evidence_member"),
|
|
200
|
+
"sources_read_started": ("total", "evidence_member"),
|
|
201
|
+
"source_read": ("source_id", "bytes", "evidence_member"),
|
|
202
|
+
"sources_parsed_started": ("total", "evidence_member"),
|
|
203
|
+
"source_parsed": ("source_id", "rows", "columns", "evidence_member"),
|
|
204
|
+
"evidence_matched": ("matched", "total", "evidence_member"),
|
|
205
|
+
"cleaning_started": ("total", "evidence_member"),
|
|
206
|
+
"cleaning_applied": (
|
|
207
|
+
"step_index",
|
|
208
|
+
"step_total",
|
|
209
|
+
"source_id",
|
|
210
|
+
"operation",
|
|
211
|
+
"evidence_member",
|
|
212
|
+
),
|
|
213
|
+
"sources_profiled": ("sources", "columns", "evidence_member"),
|
|
214
|
+
"join_completed": (
|
|
215
|
+
"left_rows",
|
|
216
|
+
"right_rows",
|
|
217
|
+
"output_rows",
|
|
218
|
+
"matched_left_rows",
|
|
219
|
+
"unmatched_left_rows",
|
|
220
|
+
"row_multiplier",
|
|
221
|
+
"cardinality",
|
|
222
|
+
"evidence_member",
|
|
223
|
+
),
|
|
224
|
+
"rows_selected": ("rows", "columns", "evidence_member"),
|
|
225
|
+
"checks_started": ("total", "evidence_member"),
|
|
226
|
+
"check_completed": ("check_id", "index", "total", "evidence_member"),
|
|
227
|
+
"checks_completed": ("passed", "total", "evidence_member"),
|
|
228
|
+
"table_written": ("rows", "columns", "evidence_member"),
|
|
229
|
+
"members_sealed_started": ("total", "evidence_member"),
|
|
230
|
+
"member_sealed": ("bytes",),
|
|
231
|
+
"candidate_installed": ("members", "bytes", "evidence_member"),
|
|
232
|
+
"snapshot_verify_started": ("total", "evidence_member"),
|
|
233
|
+
"member_verified": ("bytes",),
|
|
234
|
+
"snapshot_verified": ("members", "table_sha256", "evidence_member"),
|
|
235
|
+
"build_sealed": ("candidate_digest", "table_sha256", "manifest_version", "row_count"),
|
|
236
|
+
"build_failed": ("finding_id", "severity", "message"),
|
|
237
|
+
"run_interrupted": (),
|
|
238
|
+
"notebook_sidecar_started": (),
|
|
239
|
+
"notebook_sidecar_ready": (),
|
|
240
|
+
"notebook_sidecar_failed": ("code",),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# The fixed member each event points at. `member_sealed`, `member_verified` and `build_sealed` are
|
|
244
|
+
# absent BY DESIGN: the first two carry their pointer in `evidence` directly, one per member, and
|
|
245
|
+
# `build_sealed` anchors on the candidate digest, which is not a member of anything. The one event
|
|
246
|
+
# whose pointer varies is `source_read`; `source_member` builds it.
|
|
247
|
+
#
|
|
248
|
+
# `manifest.json` is NOT in `manifest["members"]`, so it can never appear here: `_pointer` would
|
|
249
|
+
# raise on it during projection. Every anchor below is a real member.
|
|
250
|
+
EVIDENCE_MEMBER: Mapping[str, str] = {
|
|
251
|
+
"run_started": "plan.json",
|
|
252
|
+
"rights_checked": "evidence/sources.json",
|
|
253
|
+
"sources_read_started": "plan.json",
|
|
254
|
+
"sources_parsed_started": "plan.json",
|
|
255
|
+
"source_parsed": "evidence/source_profiles.json",
|
|
256
|
+
"evidence_matched": "evidence/package.json",
|
|
257
|
+
"cleaning_started": "plan.json",
|
|
258
|
+
"cleaning_applied": "evidence/lineage.json",
|
|
259
|
+
"sources_profiled": "evidence/source_profiles.json",
|
|
260
|
+
"join_completed": "evidence/join.json",
|
|
261
|
+
# `evidence/profile.json`, not `evidence/lineage.json`: lineage redeems the column count and
|
|
262
|
+
# says nothing about rows, and the profile is the one member carrying BOTH numbers this record
|
|
263
|
+
# states. It is also what the projection actually reads.
|
|
264
|
+
"rows_selected": "evidence/profile.json",
|
|
265
|
+
"checks_started": "plan.json",
|
|
266
|
+
"check_completed": "evidence/quality.json",
|
|
267
|
+
"checks_completed": "evidence/quality.json",
|
|
268
|
+
"table_written": "data/table.parquet",
|
|
269
|
+
"members_sealed_started": "plan.json",
|
|
270
|
+
"candidate_installed": "data/table.parquet",
|
|
271
|
+
"snapshot_verify_started": "plan.json",
|
|
272
|
+
"snapshot_verified": "data/table.parquet",
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_ATTEMPT_RE = re.compile(r"\A[A-Za-z0-9._-]{1,64}\Z")
|
|
276
|
+
_SOURCE_ID_RE = re.compile(r"\A[A-Za-z0-9._-]{1,64}\Z")
|
|
277
|
+
_MEMBER_PATH_RE = re.compile(r"\A[A-Za-z0-9._/-]{1,128}\Z")
|
|
278
|
+
_SHA256_RE = re.compile(r"\A[0-9a-f]{64}\Z")
|
|
279
|
+
# A Windows drive prefix ANYWHERE in a string. The lookbehind is what keeps `https://` out of it:
|
|
280
|
+
# without it, the `s:/` inside every `https://` locator reads as a drive letter.
|
|
281
|
+
_WINDOWS_DRIVE_RE = re.compile(r"(?<![A-Za-z0-9])[A-Za-z]:[\\/]")
|
|
282
|
+
|
|
283
|
+
# A `/`-rooted path of two or more segments, ANYWHERE in a string. The guard used to test only
|
|
284
|
+
# `text.startswith("/")`, and every `BuildError` message in the tree reads
|
|
285
|
+
# "<what went wrong>: <path>", so this host's absolute paths reached the feed and both UI surfaces
|
|
286
|
+
# untouched. The lookbehind is what keeps a date (`2026/08/07`) and a relative member path
|
|
287
|
+
# (`evidence/sources.json`) out of it: a rooted path's leading slash never follows a word character.
|
|
288
|
+
_EMBEDDED_ABS_PATH_RE = re.compile(r"(?<![A-Za-z0-9_.~-])(?:/[^\s/\\'\"<>|]+){2,}")
|
|
289
|
+
|
|
290
|
+
# A scheme-qualified locator (`https://`, `s3://`, `gs://`, `abfss://`, ...), matched at the start
|
|
291
|
+
# of ONE whitespace-delimited token rather than of the whole string, so a locator quoted inside
|
|
292
|
+
# prose ("downloading from https://noaa.gov/...") is read as the locator it is.
|
|
293
|
+
_URL_SCHEME_RE = re.compile(r"\A([A-Za-z][A-Za-z0-9+.-]*)://")
|
|
294
|
+
|
|
295
|
+
# Punctuation a locator gets wrapped in when a sentence carries it.
|
|
296
|
+
_TOKEN_TRIM = "()[]{}<>\"'`,;"
|
|
297
|
+
# The schemes that mean "this machine" however they are dressed.
|
|
298
|
+
_LOCAL_URL_SCHEMES = frozenset({"file", "localhost", "unix", "fd"})
|
|
299
|
+
_LOCAL_URL_HOSTS = frozenset({"localhost", "127.0.0.1", "0.0.0.0", "::1", "0:0:0:0:0:0:0:1"})
|
|
300
|
+
|
|
301
|
+
# Computed once at import. A hostname shorter than four characters is skipped: a two-letter host
|
|
302
|
+
# name would match inside half the English language, and a guard with absurd false positives is a
|
|
303
|
+
# guard someone turns off.
|
|
304
|
+
try:
|
|
305
|
+
_HOSTNAME = socket.gethostname()
|
|
306
|
+
except OSError: # pragma: no cover - gethostname does not fail on a supported platform
|
|
307
|
+
_HOSTNAME = ""
|
|
308
|
+
_HOSTNAME_MATCH = _HOSTNAME.lower() if len(_HOSTNAME) >= 4 else ""
|
|
309
|
+
|
|
310
|
+
_RECORD_KEYS = frozenset({"seq", "at", "event", "facts", "evidence"})
|
|
311
|
+
_EVIDENCE_KEYS = frozenset({"member", "sha256"})
|
|
312
|
+
|
|
313
|
+
_ALL_EVENT_NAMES = EVENT_NAMES | RESERVED_EVENT_NAMES
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def source_member(source_id: str) -> str:
|
|
317
|
+
"""Return the candidate-relative member path carrying the raw bytes of ``source_id``.
|
|
318
|
+
|
|
319
|
+
The source id is matched against the member-path character class before it is interpolated, so
|
|
320
|
+
a source id can never widen the pointer vocabulary or introduce a traversal segment.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
if not isinstance(source_id, str) or not _SOURCE_ID_RE.match(source_id):
|
|
324
|
+
raise ValueError(f"source id is not a member-path token: {source_id!r}")
|
|
325
|
+
return f"raw/{source_id}.csv"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def validate_facts(event: str, facts: Mapping[str, Any]) -> None:
|
|
329
|
+
"""Enforce the normative fact set for ``event``; raise ``ValueError`` naming the difference.
|
|
330
|
+
|
|
331
|
+
The error names missing and extra keys. Reserved event names without an ``EVENT_FACTS`` entry
|
|
332
|
+
accept any mapping within ``MAX_FACT_KEYS``.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
if event not in _ALL_EVENT_NAMES:
|
|
336
|
+
raise ValueError(f"event name is outside the closed vocabulary: {event!r}")
|
|
337
|
+
if not isinstance(facts, Mapping):
|
|
338
|
+
raise ValueError(f"facts for {event!r} is not a mapping")
|
|
339
|
+
if any(not isinstance(key, str) for key in facts):
|
|
340
|
+
raise ValueError(f"facts for {event!r} carries a non-string key")
|
|
341
|
+
if len(facts) > MAX_FACT_KEYS:
|
|
342
|
+
raise ValueError(f"facts for {event!r} carries {len(facts)} keys, over {MAX_FACT_KEYS}")
|
|
343
|
+
|
|
344
|
+
declared = EVENT_FACTS.get(event)
|
|
345
|
+
if declared is None:
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
present = set(facts)
|
|
349
|
+
expected = set(declared)
|
|
350
|
+
if present == expected:
|
|
351
|
+
return None
|
|
352
|
+
missing = sorted(expected - present)
|
|
353
|
+
extra = sorted(present - expected)
|
|
354
|
+
raise ValueError(
|
|
355
|
+
f"facts for {event!r} do not match the declared set: "
|
|
356
|
+
f"missing={missing} extra={extra} declared={list(declared)}"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def run_feed_dir(run_dir: str | os.PathLike[str]) -> Path:
|
|
361
|
+
"""Return the feed directory for ONE run: ``<runs parent>/.mr-events/<run name>/``.
|
|
362
|
+
|
|
363
|
+
The feed lives in the runs PARENT and never inside the run directory, for two reasons that both
|
|
364
|
+
matter:
|
|
365
|
+
|
|
366
|
+
* A failed build has no run directory at all. `build_candidate` stages into a hidden temporary
|
|
367
|
+
directory and renames only at the very end, and pre-creating the output raises
|
|
368
|
+
``OUTPUT_EXISTS``. A feed inside the run directory could therefore never show a failure --
|
|
369
|
+
the exact case a watcher most wants to see.
|
|
370
|
+
* A file inside the run directory would be a new file inside a closed candidate member set
|
|
371
|
+
(G1). The feed must never be a member of anything.
|
|
372
|
+
|
|
373
|
+
It is scoped by the RUN'S OWN NAME, which is what makes a feed belong to a run. ``mr-data
|
|
374
|
+
build`` and ``mr-data recipe-run`` routinely put many runs in one parent; a flat feed directory
|
|
375
|
+
made "newest file wins" the only available rule, and a reader following it narrated one run's
|
|
376
|
+
build under another run's name and then raised a tamper refusal on the first receipt link,
|
|
377
|
+
because the digests it cited belonged to the other run. A directory per run removes the
|
|
378
|
+
ambiguity rather than resolving it: a foreign feed is not in the directory at all.
|
|
379
|
+
"""
|
|
380
|
+
|
|
381
|
+
resolved = Path(run_dir).absolute()
|
|
382
|
+
return resolved.parent / FEED_DIR_NAME / _require_run_name(resolved.name)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def feed_path(output_dir: str | os.PathLike[str], producer_attempt: str) -> Path:
|
|
386
|
+
"""Return the feed file for one build attempt: ``<run feed dir>/<attempt>.jsonl``.
|
|
387
|
+
|
|
388
|
+
``producer_attempt`` is matched against ``[A-Za-z0-9._-]{1,64}`` before it reaches a path, so an
|
|
389
|
+
attempt id can never traverse. Each attempt has its own file and timestamp origin.
|
|
390
|
+
"""
|
|
391
|
+
|
|
392
|
+
return run_feed_dir(output_dir) / f"{_require_attempt_id(producer_attempt)}.jsonl"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _require_run_name(name: str) -> str:
|
|
396
|
+
"""Refuse a run name that cannot be one path component. Never repairs, only refuses."""
|
|
397
|
+
|
|
398
|
+
if not name or name in {".", ".."} or len(name) > 128:
|
|
399
|
+
raise ValueError(f"run name is not a safe path component: {name!r}")
|
|
400
|
+
if "/" in name or "\\" in name or "\x00" in name:
|
|
401
|
+
raise ValueError(f"run name is not a safe path component: {name!r}")
|
|
402
|
+
return name
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _require_attempt_id(producer_attempt: str) -> str:
|
|
406
|
+
if not isinstance(producer_attempt, str) or not _ATTEMPT_RE.match(producer_attempt):
|
|
407
|
+
raise ValueError(f"producer attempt id is not a safe token: {producer_attempt!r}")
|
|
408
|
+
if producer_attempt in {".", ".."}:
|
|
409
|
+
raise ValueError(f"producer attempt id is not a safe token: {producer_attempt!r}")
|
|
410
|
+
return producer_attempt
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _require_member_path(value: Any, key: str) -> None:
|
|
414
|
+
if not isinstance(value, str) or not _MEMBER_PATH_RE.match(value):
|
|
415
|
+
raise ValueError(f"{key} is not a candidate-relative member path: {value!r}")
|
|
416
|
+
if value.startswith("/") or ".." in value.split("/"):
|
|
417
|
+
raise ValueError(f"{key} is not a candidate-relative member path: {value!r}")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _iter_strings(value: Any, key: str) -> Any:
|
|
421
|
+
"""Yield every ``(key, string)`` pair reachable in a fact or evidence value."""
|
|
422
|
+
|
|
423
|
+
if isinstance(value, str):
|
|
424
|
+
yield key, value
|
|
425
|
+
elif isinstance(value, Mapping):
|
|
426
|
+
for inner_key, inner in value.items():
|
|
427
|
+
yield from _iter_strings(inner, f"{key}.{inner_key}")
|
|
428
|
+
elif isinstance(value, (list, tuple)):
|
|
429
|
+
for index, item in enumerate(value):
|
|
430
|
+
yield from _iter_strings(item, f"{key}[{index}]")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
class PlaceNamedError(ValueError):
|
|
434
|
+
"""A fact string named a local place instead of portable evidence.
|
|
435
|
+
|
|
436
|
+
A distinct type because a distinct RECOVERY applies. A producer whose fact KEYS drifted from
|
|
437
|
+
``EVENT_FACTS`` is a code defect and disarms the sink deliberately, so the drift shows up as a
|
|
438
|
+
truncated feed rather than as a feed full of wrong records. A fact whose VALUE happened to
|
|
439
|
+
carry a path is not that: the record is still true, only unportable, so the sink redacts the
|
|
440
|
+
offending strings and continues writing the feed.
|
|
441
|
+
"""
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class UnwritableTextError(ValueError):
|
|
445
|
+
"""A fact string carried text no surface can encode as UTF-8 -- a lone surrogate.
|
|
446
|
+
|
|
447
|
+
A distinct type for the same reason ``PlaceNamedError`` is one: a distinct RECOVERY applies.
|
|
448
|
+
The record is still true, only unwritable, so the sink repairs the offending strings through
|
|
449
|
+
``writable_text`` and keeps narrating rather than disarming. See ``writable_text`` for what a
|
|
450
|
+
lone surrogate costs every surface that has to encode it.
|
|
451
|
+
"""
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _require_location_blind(record: Mapping[str, Any]) -> None:
|
|
455
|
+
"""Refuse any record that names a local place instead of portable evidence.
|
|
456
|
+
|
|
457
|
+
Local and hosted workers must emit identical records. Absolute paths, home directories,
|
|
458
|
+
``file://`` URLs, UNC prefixes, and host names are therefore refused. Pointers use a
|
|
459
|
+
candidate-relative member path and SHA-256 digest.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
evidence = record.get("evidence")
|
|
463
|
+
if evidence is not None:
|
|
464
|
+
if not isinstance(evidence, Mapping) or set(evidence) != _EVIDENCE_KEYS:
|
|
465
|
+
raise ValueError("evidence must be exactly {'member', 'sha256'} or None")
|
|
466
|
+
_require_member_path(evidence["member"], "evidence.member")
|
|
467
|
+
if not isinstance(evidence["sha256"], str) or not _SHA256_RE.match(evidence["sha256"]):
|
|
468
|
+
raise ValueError(f"evidence.sha256 is not a sha256 digest: {evidence['sha256']!r}")
|
|
469
|
+
|
|
470
|
+
facts = record.get("facts") or {}
|
|
471
|
+
if "evidence_member" in facts:
|
|
472
|
+
_require_member_path(facts["evidence_member"], "facts.evidence_member")
|
|
473
|
+
|
|
474
|
+
for scope, container in (("facts", facts), ("evidence", evidence)):
|
|
475
|
+
if container is None:
|
|
476
|
+
continue
|
|
477
|
+
for key, text in _iter_strings(container, scope):
|
|
478
|
+
_require_placeless(key, text)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _locator_tokens(text: str) -> Iterator[str]:
|
|
482
|
+
"""Yield the whitespace-delimited tokens of ``text``, unwrapped from sentence punctuation.
|
|
483
|
+
|
|
484
|
+
The guard reads a fact string token by token rather than as one blob because local paths and
|
|
485
|
+
remote locators can both live inside prose. ``output already exists: /private/tmp/run`` carries
|
|
486
|
+
a place in its last token; ``downloading from https://noaa.gov/t00z.grib2`` carries a placeless
|
|
487
|
+
locator in its last token. A rule anchored at position 0 gets both wrong in opposite directions.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
for raw in text.split():
|
|
491
|
+
token = raw.strip(_TOKEN_TRIM)
|
|
492
|
+
if token:
|
|
493
|
+
yield token
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _require_placeless(key: str, text: str) -> None:
|
|
497
|
+
"""Refuse a string that names THIS machine. A remote locator is not such a string.
|
|
498
|
+
|
|
499
|
+
The invariant is portability, not punctuation: the record a Cloud Run job emits for a Build
|
|
500
|
+
must be the record this laptop emits for it. ``/Users/rob/runs/out.csv`` fails that test;
|
|
501
|
+
``https://noaa.gov/hrrr/t00z.grib2`` passes it, because the URL resolves to the same bytes from
|
|
502
|
+
either machine. Both tests apply WHEREVER the string in question sits:
|
|
503
|
+
|
|
504
|
+
* a place is refused anywhere -- ``output already exists: /private/tmp/run`` is the shape every
|
|
505
|
+
``BuildError`` message in this tree has, and a guard anchored at position 0 admitted all of
|
|
506
|
+
them, so a real ``build_failed`` record carried this disk's paths onto the feed and onto both
|
|
507
|
+
UI surfaces;
|
|
508
|
+
* a scheme-qualified remote locator is admitted anywhere -- the guard used to refuse every
|
|
509
|
+
string containing ``//``, which refused exactly the fact the Courier and Reader lanes carry.
|
|
510
|
+
|
|
511
|
+
Still refused, however the string is dressed: ``file://`` and any other local scheme, a rooted
|
|
512
|
+
absolute path, a Windows drive, a UNC share, ``~``, a loopback or ``.localhost`` authority
|
|
513
|
+
(this machine wearing a URL), and this host's own name.
|
|
514
|
+
|
|
515
|
+
Raises :class:`PlaceNamedError` so a caller can tell "this record is unportable" (redactable)
|
|
516
|
+
apart from "this record is malformed" (a producer defect).
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
lowered = text.lower()
|
|
520
|
+
if "file://" in lowered:
|
|
521
|
+
raise PlaceNamedError(f"{key} names a file URL: {text!r}")
|
|
522
|
+
# A UNC share, checked literally rather than through `os.sep` so the rule is the same on every
|
|
523
|
+
# platform (on POSIX `os.sep + os.sep` is just `//`, which the per-token branch below catches).
|
|
524
|
+
if "\\\\" in text:
|
|
525
|
+
raise PlaceNamedError(f"{key} names a network share: {text!r}")
|
|
526
|
+
if _HOSTNAME_MATCH and _HOSTNAME_MATCH in lowered:
|
|
527
|
+
raise PlaceNamedError(f"{key} names this machine")
|
|
528
|
+
|
|
529
|
+
for token in _locator_tokens(text):
|
|
530
|
+
scheme_match = _URL_SCHEME_RE.match(token)
|
|
531
|
+
if scheme_match is not None:
|
|
532
|
+
scheme = scheme_match.group(1).lower()
|
|
533
|
+
if scheme in _LOCAL_URL_SCHEMES:
|
|
534
|
+
raise PlaceNamedError(f"{key} names a location on this machine: {token!r}")
|
|
535
|
+
if _authority_is_local(token.lower()[scheme_match.end() :]):
|
|
536
|
+
raise PlaceNamedError(f"{key} names this machine: {token!r}")
|
|
537
|
+
# A remote locator means the same thing from every machine, so it is placeless.
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
if token.startswith("/") or _EMBEDDED_ABS_PATH_RE.search(token):
|
|
541
|
+
raise PlaceNamedError(f"{key} names an absolute path: {token!r}")
|
|
542
|
+
if _WINDOWS_DRIVE_RE.search(token):
|
|
543
|
+
raise PlaceNamedError(f"{key} names an absolute path: {token!r}")
|
|
544
|
+
if token.startswith("~") or "~/" in token:
|
|
545
|
+
raise PlaceNamedError(f"{key} names a home directory: {token!r}")
|
|
546
|
+
if "//" in token:
|
|
547
|
+
raise PlaceNamedError(f"{key} names a network or URL location: {token!r}")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _authority_is_local(remainder: str) -> bool:
|
|
551
|
+
"""True when a URL's authority is a loopback name this machine alone resolves."""
|
|
552
|
+
|
|
553
|
+
authority = remainder.split("/", 1)[0].split("?", 1)[0].split("#", 1)[0]
|
|
554
|
+
authority = authority.rsplit("@", 1)[-1]
|
|
555
|
+
if authority.startswith("["):
|
|
556
|
+
host = authority.split("]", 1)[0].lstrip("[")
|
|
557
|
+
else:
|
|
558
|
+
host = authority.split(":", 1)[0]
|
|
559
|
+
return host in _LOCAL_URL_HOSTS or host.endswith(".localhost")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _normalize_json(value: Any, *, key: str, depth: int = 0) -> Any:
|
|
563
|
+
"""Return a JSON-safe copy of ``value`` or raise ``ValueError``.
|
|
564
|
+
|
|
565
|
+
Fact values are JSON scalars, lists, and flat mappings of scalars -- `rights_checked` reports
|
|
566
|
+
one mapping per source, and a tuple on a dataclass has to arrive here as a list because JSON has
|
|
567
|
+
no tuple. Depth and width are bounded so a fact value can never be a document.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
if depth > _MAX_FACT_DEPTH:
|
|
571
|
+
raise ValueError(f"fact {key!r} nests deeper than {_MAX_FACT_DEPTH}")
|
|
572
|
+
if value is None or isinstance(value, bool):
|
|
573
|
+
return value
|
|
574
|
+
if isinstance(value, str):
|
|
575
|
+
if writable_text(value) != value:
|
|
576
|
+
# BESIDE THE NON-FINITE FLOAT REFUSAL, and for the identical reason: a fact this writer
|
|
577
|
+
# accepts must be one every surface downstream can encode. `_dumps` uses
|
|
578
|
+
# `ensure_ascii=True`, so a lone surrogate was written out as a pure-ASCII `\ud800`
|
|
579
|
+
# escape and read back as a lone surrogate -- the writer's boundary and the reader's
|
|
580
|
+
# boundary were both open on the same value class. Refused here rather than repaired,
|
|
581
|
+
# so a producer that builds a fact out of undecodable bytes learns it; `event_sink`
|
|
582
|
+
# turns the refusal into a repaired record rather than a lost one, which is exactly
|
|
583
|
+
# what it already does for a fact that named a place.
|
|
584
|
+
raise UnwritableTextError(f"fact {key!r} carries text no surface can encode")
|
|
585
|
+
return value
|
|
586
|
+
if isinstance(value, int):
|
|
587
|
+
return value
|
|
588
|
+
if isinstance(value, float):
|
|
589
|
+
if not math.isfinite(value):
|
|
590
|
+
raise ValueError(f"fact {key!r} is not a finite number")
|
|
591
|
+
return value
|
|
592
|
+
if isinstance(value, Mapping):
|
|
593
|
+
if len(value) > MAX_FACT_KEYS:
|
|
594
|
+
raise ValueError(f"fact {key!r} carries more than {MAX_FACT_KEYS} keys")
|
|
595
|
+
normalized: dict[str, Any] = {}
|
|
596
|
+
for inner_key, inner in value.items():
|
|
597
|
+
if not isinstance(inner_key, str):
|
|
598
|
+
raise ValueError(f"fact {key!r} carries a non-string key")
|
|
599
|
+
if writable_text(inner_key) != inner_key:
|
|
600
|
+
raise UnwritableTextError(f"fact {key!r} carries a key no surface can encode")
|
|
601
|
+
normalized[inner_key] = _normalize_json(
|
|
602
|
+
inner, key=f"{key}.{inner_key}", depth=depth + 1
|
|
603
|
+
)
|
|
604
|
+
return normalized
|
|
605
|
+
if isinstance(value, Sequence):
|
|
606
|
+
items = list(value)
|
|
607
|
+
if len(items) > _MAX_LIST_ITEMS:
|
|
608
|
+
raise ValueError(f"fact {key!r} carries more than {_MAX_LIST_ITEMS} items")
|
|
609
|
+
return [_normalize_json(item, key=f"{key}[]", depth=depth + 1) for item in items]
|
|
610
|
+
raise ValueError(f"fact {key!r} is not a JSON value: {type(value).__name__}")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _normalize_facts(event: str, facts: Mapping[str, Any]) -> dict[str, Any]:
|
|
614
|
+
return {key: _normalize_json(value, key=key) for key, value in facts.items()}
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def open_regular_file(
|
|
618
|
+
name: str | os.PathLike[str],
|
|
619
|
+
*,
|
|
620
|
+
dir_fd: int | None = None,
|
|
621
|
+
flags: int = os.O_RDONLY,
|
|
622
|
+
mode: int = 0o600,
|
|
623
|
+
) -> int:
|
|
624
|
+
"""Open ``name`` as a nonblocking regular file without following links.
|
|
625
|
+
|
|
626
|
+
``fstat`` validates the opened descriptor. With ``dir_fd``, each path component is opened using
|
|
627
|
+
``O_DIRECTORY | O_NOFOLLOW`` beneath the caller's trusted root. Without ``dir_fd``, only the
|
|
628
|
+
leaf is validated.
|
|
629
|
+
"""
|
|
630
|
+
|
|
631
|
+
parent_fd, leaf, borrowed = _descend_to_parent(name, dir_fd)
|
|
632
|
+
try:
|
|
633
|
+
handle_fd = os.open(leaf, flags | _O_NOFOLLOW | _O_NONBLOCK, mode, dir_fd=parent_fd)
|
|
634
|
+
finally:
|
|
635
|
+
if not borrowed and parent_fd is not None:
|
|
636
|
+
os.close(parent_fd)
|
|
637
|
+
try:
|
|
638
|
+
opened = os.fstat(handle_fd)
|
|
639
|
+
if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1:
|
|
640
|
+
raise OSError("not a regular file where a regular file should be")
|
|
641
|
+
except BaseException:
|
|
642
|
+
os.close(handle_fd)
|
|
643
|
+
raise
|
|
644
|
+
return handle_fd
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def open_directory(name: str | os.PathLike[str], *, dir_fd: int | None = None) -> int:
|
|
648
|
+
"""Open ``name`` as a directory, refusing a symlink at every component ``name`` names.
|
|
649
|
+
|
|
650
|
+
The directory half of ``open_regular_file``, with the same trust rule: with ``dir_fd`` every
|
|
651
|
+
component of ``name`` is proved, without it the directory part of ``name`` is the caller's own
|
|
652
|
+
and the leaf is proved. Callers hold the returned descriptor and read relative to it, so the
|
|
653
|
+
directory measured and the directory read are the same object however the name is rebound
|
|
654
|
+
afterwards.
|
|
655
|
+
"""
|
|
656
|
+
|
|
657
|
+
parent_fd, leaf, borrowed = _descend_to_parent(name, dir_fd)
|
|
658
|
+
try:
|
|
659
|
+
return _open_component_directory(leaf, parent_fd)
|
|
660
|
+
finally:
|
|
661
|
+
if not borrowed and parent_fd is not None:
|
|
662
|
+
os.close(parent_fd)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def duplicate_directory(directory_fd: int) -> int:
|
|
666
|
+
"""Return an independently owned reference to an already proved directory descriptor."""
|
|
667
|
+
|
|
668
|
+
duplicate = os.dup(directory_fd)
|
|
669
|
+
try:
|
|
670
|
+
if not stat.S_ISDIR(os.fstat(duplicate).st_mode):
|
|
671
|
+
raise OSError(errno.ENOTDIR, "not a directory where a directory should be")
|
|
672
|
+
except BaseException:
|
|
673
|
+
os.close(duplicate)
|
|
674
|
+
raise
|
|
675
|
+
return duplicate
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _open_component_directory(name: str, dir_fd: int | None) -> int:
|
|
679
|
+
"""Open ONE path component as a directory. Never follows a symlink, never blocks."""
|
|
680
|
+
|
|
681
|
+
fd = os.open(name, os.O_RDONLY | _O_DIRECTORY | _O_NOFOLLOW | _O_NONBLOCK, dir_fd=dir_fd)
|
|
682
|
+
try:
|
|
683
|
+
if not stat.S_ISDIR(os.fstat(fd).st_mode):
|
|
684
|
+
# Reachable only where `O_DIRECTORY` does not exist, in which case the flag is 0 and
|
|
685
|
+
# the open accepted a regular file. Asked of the DESCRIPTOR, as everywhere else here.
|
|
686
|
+
raise OSError(errno.ENOTDIR, "not a directory where a directory should be")
|
|
687
|
+
except BaseException:
|
|
688
|
+
os.close(fd)
|
|
689
|
+
raise
|
|
690
|
+
return fd
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _descend_to_parent(
|
|
694
|
+
name: str | os.PathLike[str], dir_fd: int | None
|
|
695
|
+
) -> tuple[int | None, str, bool]:
|
|
696
|
+
"""Return ``(parent_fd, leaf, borrowed)`` for ``name``. ``borrowed`` fds are the caller's.
|
|
697
|
+
|
|
698
|
+
Below a ``dir_fd`` every component is opened on its own with ``O_NOFOLLOW``, so a symlinked
|
|
699
|
+
directory component is refused instead of followed. ``.`` and ``..`` are refused outright: a
|
|
700
|
+
descent that honoured them would walk back out of the directory the caller proved, which is
|
|
701
|
+
the whole thing the descent exists to prevent.
|
|
702
|
+
"""
|
|
703
|
+
|
|
704
|
+
parts = PurePath(os.fspath(name)).parts
|
|
705
|
+
if not parts:
|
|
706
|
+
raise OSError(errno.ENOENT, "an empty name opens nothing")
|
|
707
|
+
if dir_fd is None:
|
|
708
|
+
if len(parts) == 1:
|
|
709
|
+
return None, parts[0], True
|
|
710
|
+
prefix = os.path.dirname(os.fspath(name))
|
|
711
|
+
return os.open(prefix, os.O_RDONLY | _O_DIRECTORY), parts[-1], False
|
|
712
|
+
if os.path.isabs(os.fspath(name)):
|
|
713
|
+
raise OSError(errno.EINVAL, "an absolute name is not relative to a directory")
|
|
714
|
+
|
|
715
|
+
parent_fd, borrowed = dir_fd, True
|
|
716
|
+
try:
|
|
717
|
+
for part in parts[:-1]:
|
|
718
|
+
if part in (os.curdir, os.pardir):
|
|
719
|
+
raise OSError(errno.EINVAL, f"a member path may not contain {part!r}")
|
|
720
|
+
child = _open_component_directory(part, parent_fd)
|
|
721
|
+
if not borrowed:
|
|
722
|
+
os.close(parent_fd)
|
|
723
|
+
parent_fd, borrowed = child, False
|
|
724
|
+
except BaseException:
|
|
725
|
+
if not borrowed:
|
|
726
|
+
os.close(parent_fd)
|
|
727
|
+
raise
|
|
728
|
+
return parent_fd, parts[-1], borrowed
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _regular_file_identity(info: os.stat_result) -> tuple[int, ...]:
|
|
732
|
+
"""Identity fields that must stay fixed while a mutable plain file is read."""
|
|
733
|
+
|
|
734
|
+
return (
|
|
735
|
+
info.st_dev,
|
|
736
|
+
info.st_ino,
|
|
737
|
+
info.st_mode,
|
|
738
|
+
info.st_nlink,
|
|
739
|
+
info.st_uid,
|
|
740
|
+
info.st_gid,
|
|
741
|
+
info.st_size,
|
|
742
|
+
info.st_mtime_ns,
|
|
743
|
+
info.st_ctime_ns,
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def read_regular_bytes(
|
|
748
|
+
path: str | os.PathLike[str], *, dir_fd: int | None = None, max_bytes: int
|
|
749
|
+
) -> bytes:
|
|
750
|
+
"""Read one regular file whole through ``open_regular_file``. Raises ``OSError``, never blocks.
|
|
751
|
+
|
|
752
|
+
The descriptor-based read rejects symlinks and non-regular files without a separate path lookup.
|
|
753
|
+
Nonblocking open prevents a FIFO from stalling the caller.
|
|
754
|
+
|
|
755
|
+
``max_bytes`` refuses rather than allocates, and the size it tests comes off the DESCRIPTOR, not
|
|
756
|
+
off a second look at the path. The read is bounded one byte past the ceiling so a file that grew
|
|
757
|
+
between the two is refused as oversized rather than read without a limit.
|
|
758
|
+
|
|
759
|
+
``dir_fd`` reads ``path`` relative to a directory descriptor the caller already holds, which is
|
|
760
|
+
how ``rederive_feed`` reads a candidate the verify has pinned open rather than re-walking the
|
|
761
|
+
run directory by name. It is the same argument ``watch`` passes for the same reason.
|
|
762
|
+
"""
|
|
763
|
+
|
|
764
|
+
handle_fd = open_regular_file(path, dir_fd=dir_fd)
|
|
765
|
+
try:
|
|
766
|
+
before = os.fstat(handle_fd)
|
|
767
|
+
if before.st_size > max_bytes:
|
|
768
|
+
raise OSError("larger than this reader's ceiling")
|
|
769
|
+
chunks: list[bytes] = []
|
|
770
|
+
remaining = max_bytes + 1
|
|
771
|
+
while remaining > 0:
|
|
772
|
+
chunk = os.read(handle_fd, min(remaining, 1 << 16))
|
|
773
|
+
if not chunk:
|
|
774
|
+
break
|
|
775
|
+
chunks.append(chunk)
|
|
776
|
+
remaining -= len(chunk)
|
|
777
|
+
payload = b"".join(chunks)
|
|
778
|
+
after = os.fstat(handle_fd)
|
|
779
|
+
finally:
|
|
780
|
+
os.close(handle_fd)
|
|
781
|
+
if len(payload) > max_bytes:
|
|
782
|
+
raise OSError("larger than this reader's ceiling")
|
|
783
|
+
if _regular_file_identity(before) != _regular_file_identity(after):
|
|
784
|
+
raise OSError("regular file changed while it was read")
|
|
785
|
+
named = os.stat(path, dir_fd=dir_fd, follow_symlinks=False)
|
|
786
|
+
if not stat.S_ISREG(named.st_mode) or named.st_nlink != 1:
|
|
787
|
+
raise OSError("not a regular file where a regular file should be")
|
|
788
|
+
if _regular_file_identity(before) != _regular_file_identity(named):
|
|
789
|
+
raise OSError("regular file name changed while it was read")
|
|
790
|
+
return payload
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def snapshot_candidate_namespace(
|
|
794
|
+
candidate_fd: int,
|
|
795
|
+
members: Sequence[str],
|
|
796
|
+
*,
|
|
797
|
+
manifest_name: str = "manifest.json",
|
|
798
|
+
) -> tuple[tuple[tuple[str, ...], tuple[int, ...], tuple[str, ...]], ...]:
|
|
799
|
+
"""Return the exact descriptor-relative directory namespace for candidate members.
|
|
800
|
+
|
|
801
|
+
Every directory is opened through :func:`open_directory`, and its identity is checked before
|
|
802
|
+
and after listing. The result is suitable as one component of a retained proof: an added,
|
|
803
|
+
removed, replaced, or permission-changed directory invalidates the proof even when all named
|
|
804
|
+
member files are otherwise unchanged.
|
|
805
|
+
"""
|
|
806
|
+
|
|
807
|
+
expected: dict[tuple[str, ...], set[str]] = {(): {manifest_name}}
|
|
808
|
+
for member in members:
|
|
809
|
+
_require_member_path(member, "manifest member path")
|
|
810
|
+
parts = tuple(member.split("/"))
|
|
811
|
+
parent: tuple[str, ...] = ()
|
|
812
|
+
for component in parts[:-1]:
|
|
813
|
+
expected.setdefault(parent, set()).add(component)
|
|
814
|
+
parent = (*parent, component)
|
|
815
|
+
expected.setdefault(parent, set())
|
|
816
|
+
expected.setdefault(parent, set()).add(parts[-1])
|
|
817
|
+
|
|
818
|
+
observed: list[tuple[tuple[str, ...], tuple[int, ...], tuple[str, ...]]] = []
|
|
819
|
+
for parts, expected_children in sorted(expected.items()):
|
|
820
|
+
directory_fd = (
|
|
821
|
+
os.dup(candidate_fd)
|
|
822
|
+
if not parts
|
|
823
|
+
else open_directory("/".join(parts), dir_fd=candidate_fd)
|
|
824
|
+
)
|
|
825
|
+
try:
|
|
826
|
+
before = os.fstat(directory_fd)
|
|
827
|
+
children = tuple(sorted(os.listdir(directory_fd)))
|
|
828
|
+
after = os.fstat(directory_fd)
|
|
829
|
+
finally:
|
|
830
|
+
os.close(directory_fd)
|
|
831
|
+
before_identity = _directory_identity(before)
|
|
832
|
+
if before_identity != _directory_identity(after):
|
|
833
|
+
raise OSError("candidate directory changed while its namespace was listed")
|
|
834
|
+
if set(children) != expected_children:
|
|
835
|
+
raise OSError("candidate namespace differs from its manifest")
|
|
836
|
+
observed.append((parts, before_identity, children))
|
|
837
|
+
return tuple(observed)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _directory_identity(info: os.stat_result) -> tuple[int, ...]:
|
|
841
|
+
return (
|
|
842
|
+
info.st_dev,
|
|
843
|
+
info.st_ino,
|
|
844
|
+
info.st_mode,
|
|
845
|
+
info.st_nlink,
|
|
846
|
+
info.st_uid,
|
|
847
|
+
info.st_gid,
|
|
848
|
+
info.st_mtime_ns,
|
|
849
|
+
info.st_ctime_ns,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _create_feed_directory(feed_dir: str | os.PathLike[str]) -> int:
|
|
854
|
+
"""Create a private feed directory and return its validated descriptor.
|
|
855
|
+
|
|
856
|
+
Each component is created relative to its parent descriptor, opened with
|
|
857
|
+
``O_DIRECTORY | O_NOFOLLOW``, checked for ownership, and tightened to mode 0700. Directories
|
|
858
|
+
owned by another principal are refused.
|
|
859
|
+
"""
|
|
860
|
+
|
|
861
|
+
feed_dir = Path(feed_dir)
|
|
862
|
+
feed_dir.parent.parent.mkdir(parents=True, exist_ok=True)
|
|
863
|
+
root_fd = os.open(feed_dir.parent.parent, os.O_RDONLY | _O_DIRECTORY)
|
|
864
|
+
try:
|
|
865
|
+
events_fd = _create_private_directory(feed_dir.parent.name, root_fd)
|
|
866
|
+
finally:
|
|
867
|
+
os.close(root_fd)
|
|
868
|
+
try:
|
|
869
|
+
return _create_private_directory(feed_dir.name, events_fd)
|
|
870
|
+
finally:
|
|
871
|
+
os.close(events_fd)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _create_private_directory(name: str, dir_fd: int) -> int:
|
|
875
|
+
"""Create one component ``0o700`` under ``dir_fd`` and return a descriptor that proves it."""
|
|
876
|
+
|
|
877
|
+
try:
|
|
878
|
+
os.mkdir(name, 0o700, dir_fd=dir_fd)
|
|
879
|
+
except FileExistsError:
|
|
880
|
+
pass
|
|
881
|
+
fd = _open_component_directory(name, dir_fd)
|
|
882
|
+
try:
|
|
883
|
+
info = os.fstat(fd)
|
|
884
|
+
getuid = getattr(os, "getuid", None)
|
|
885
|
+
if getuid is not None and info.st_uid != getuid():
|
|
886
|
+
raise OSError(errno.EPERM, f"feed directory {name!r} belongs to another user")
|
|
887
|
+
if stat.S_IMODE(info.st_mode) != 0o700:
|
|
888
|
+
# Ours, so it is tightened rather than refused -- through the descriptor, so the
|
|
889
|
+
# object whose mode changes is the object that was proved.
|
|
890
|
+
os.fchmod(fd, 0o700)
|
|
891
|
+
except BaseException:
|
|
892
|
+
os.close(fd)
|
|
893
|
+
raise
|
|
894
|
+
return fd
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
class EventWriter:
|
|
898
|
+
"""An append-only writer over one feed file.
|
|
899
|
+
|
|
900
|
+
One descriptor per feed, opened ``O_APPEND`` and written one whole line per ``os.write`` call,
|
|
901
|
+
so a reader tailing the file never sees a torn record and two writers on the same feed cannot
|
|
902
|
+
interleave a line. ``O_NOFOLLOW`` because another local user may have swapped a symlink in.
|
|
903
|
+
"""
|
|
904
|
+
|
|
905
|
+
def __init__(self, path: str | os.PathLike[str], *, run: str | None = None) -> None:
|
|
906
|
+
self._path = Path(path)
|
|
907
|
+
self._run = _require_attempt_id(run if run is not None else self._path.stem)
|
|
908
|
+
self._lock = threading.Lock()
|
|
909
|
+
|
|
910
|
+
existing = read_feed(self._path)
|
|
911
|
+
self._seq = int(existing[-1]["seq"]) if existing else 0
|
|
912
|
+
self._last_at = float(existing[-1]["at"]) if existing else 0.0
|
|
913
|
+
torn_tail = _ends_mid_record(self._path)
|
|
914
|
+
|
|
915
|
+
feed_dir_fd = _create_feed_directory(self._path.parent)
|
|
916
|
+
try:
|
|
917
|
+
# Through the same descriptor-checked open the readers use, and RELATIVE to the
|
|
918
|
+
# directory descriptor just proved. A FIFO where the feed should be blocks a WRITE open
|
|
919
|
+
# too -- until some process opens it for reading, which may be never -- so `mr-data
|
|
920
|
+
# run` hung while arming its feed, before the build had done anything at all.
|
|
921
|
+
self._fd: int | None = open_regular_file(
|
|
922
|
+
self._path.name,
|
|
923
|
+
dir_fd=feed_dir_fd,
|
|
924
|
+
flags=os.O_WRONLY | os.O_CREAT | os.O_APPEND,
|
|
925
|
+
mode=0o600,
|
|
926
|
+
)
|
|
927
|
+
finally:
|
|
928
|
+
os.close(feed_dir_fd)
|
|
929
|
+
if os.fstat(self._fd).st_size == 0:
|
|
930
|
+
header = {
|
|
931
|
+
"v": 1,
|
|
932
|
+
"kind": "header",
|
|
933
|
+
"schema_version": FEED_SCHEMA_VERSION,
|
|
934
|
+
"run": self._run,
|
|
935
|
+
}
|
|
936
|
+
os.write(self._fd, (_dumps(header) + "\n").encode("utf-8"))
|
|
937
|
+
elif torn_tail:
|
|
938
|
+
# The feed ends mid-record: a previous writer died, or the file was truncated. Close the
|
|
939
|
+
# torn line off with a newline before appending, so the next record starts on a line of
|
|
940
|
+
# its own instead of being glued onto the wreckage. The torn line stays unreadable --
|
|
941
|
+
# that is correct, it was never complete -- but every record after it is readable again.
|
|
942
|
+
os.write(self._fd, b"\n")
|
|
943
|
+
|
|
944
|
+
@property
|
|
945
|
+
def path(self) -> Path:
|
|
946
|
+
return self._path
|
|
947
|
+
|
|
948
|
+
def write(
|
|
949
|
+
self,
|
|
950
|
+
event: str,
|
|
951
|
+
facts: Mapping[str, Any],
|
|
952
|
+
evidence: Mapping[str, Any] | None = None,
|
|
953
|
+
) -> None:
|
|
954
|
+
"""Append one record. Raises ``ValueError`` on anything the vocabulary does not allow."""
|
|
955
|
+
|
|
956
|
+
if event not in _ALL_EVENT_NAMES:
|
|
957
|
+
raise ValueError(f"event name is outside the closed vocabulary: {event!r}")
|
|
958
|
+
validate_facts(event, facts)
|
|
959
|
+
normalized_facts = _normalize_facts(event, facts)
|
|
960
|
+
normalized_evidence = None if evidence is None else dict(evidence)
|
|
961
|
+
|
|
962
|
+
with self._lock:
|
|
963
|
+
if self._fd is None:
|
|
964
|
+
raise ValueError("event writer is closed")
|
|
965
|
+
now = time.time()
|
|
966
|
+
if now < self._last_at:
|
|
967
|
+
# A feed's `at` is strictly non-decreasing: a reader draws elapsed time from it and
|
|
968
|
+
# a clock that stepped backwards must not read as a stage that took negative time.
|
|
969
|
+
now = self._last_at
|
|
970
|
+
record = {
|
|
971
|
+
"seq": self._seq + 1,
|
|
972
|
+
"at": now,
|
|
973
|
+
"event": event,
|
|
974
|
+
"facts": normalized_facts,
|
|
975
|
+
"evidence": normalized_evidence,
|
|
976
|
+
}
|
|
977
|
+
_require_location_blind(record)
|
|
978
|
+
line = (_dumps(record) + "\n").encode("utf-8")
|
|
979
|
+
os.write(self._fd, line)
|
|
980
|
+
self._seq += 1
|
|
981
|
+
self._last_at = now
|
|
982
|
+
|
|
983
|
+
def close(self) -> None:
|
|
984
|
+
with self._lock:
|
|
985
|
+
if self._fd is not None:
|
|
986
|
+
os.close(self._fd)
|
|
987
|
+
self._fd = None
|
|
988
|
+
|
|
989
|
+
def __enter__(self) -> EventWriter:
|
|
990
|
+
return self
|
|
991
|
+
|
|
992
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
993
|
+
self.close()
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _ends_mid_record(path: Path) -> bool:
|
|
997
|
+
"""True when the feed exists, is non-empty, and its last byte is not a newline.
|
|
998
|
+
|
|
999
|
+
The size comes off the same descriptor the seek uses, rather than off a ``stat`` of the name
|
|
1000
|
+
followed by an open of the name: the second look is the one another local user gets to answer.
|
|
1001
|
+
"""
|
|
1002
|
+
|
|
1003
|
+
try:
|
|
1004
|
+
with os.fdopen(open_regular_file(path), "rb") as handle:
|
|
1005
|
+
size = os.fstat(handle.fileno()).st_size
|
|
1006
|
+
if size == 0:
|
|
1007
|
+
return False
|
|
1008
|
+
handle.seek(size - 1)
|
|
1009
|
+
return handle.read(1) != b"\n"
|
|
1010
|
+
except OSError:
|
|
1011
|
+
return False
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def _dumps(payload: Mapping[str, Any]) -> str:
|
|
1015
|
+
return json.dumps(
|
|
1016
|
+
payload, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def read_feed(path: str | os.PathLike[str]) -> list[dict[str, Any]]:
|
|
1021
|
+
"""Read a feed. This is the untrusted-input boundary: it never raises for a malformed feed.
|
|
1022
|
+
|
|
1023
|
+
A missing file, a directory, an oversized file, an absent or foreign header -- each reads back
|
|
1024
|
+
as no feed at all. A feed is ignored WHOLE or understood; it is never half-understood. A
|
|
1025
|
+
trailing line with no newline is the torn record a concurrent writer is mid-way through, and it
|
|
1026
|
+
is dropped rather than parsed.
|
|
1027
|
+
|
|
1028
|
+
``read_feed`` deliberately does NOT call ``validate_facts``. A reader that refused a record
|
|
1029
|
+
because its keys drifted would turn a producer bug into a blank page; the reader's job is to
|
|
1030
|
+
show whatever the feed safely said, and the fact spec is enforced where the record is written.
|
|
1031
|
+
|
|
1032
|
+
An empty result is TWO states, and a reader that must tell them apart asks ``feed_is_readable``.
|
|
1033
|
+
"""
|
|
1034
|
+
|
|
1035
|
+
return _read_feed_parts(path)[1]
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def feed_is_readable(path: str | os.PathLike[str]) -> bool:
|
|
1039
|
+
"""Whether these bytes read back AS one of our feeds, however little the feed says.
|
|
1040
|
+
|
|
1041
|
+
``read_feed`` returns ``[]`` for two states a reader must never confuse.
|
|
1042
|
+
|
|
1043
|
+
BYTES THAT ARE NOT A FEED read as nothing: a missing or oversized file, a foreign or absent
|
|
1044
|
+
header, a first line still being written. A reader should disbelieve them and keep whatever it
|
|
1045
|
+
last understood.
|
|
1046
|
+
|
|
1047
|
+
AN ATTEMPT THAT NARRATED NOTHING is a perfectly good feed that has no records yet, or will never
|
|
1048
|
+
have any. It is the routine product of an idempotent re-run -- ``mr-data run`` on an
|
|
1049
|
+
already-built workspace returns the existing result without rebuilding, so it arms a feed and
|
|
1050
|
+
writes only the header -- and it is also every armed feed for the instant before its first
|
|
1051
|
+
record. Treating it as unreadable bytes made a completed, verifying run render as though nothing
|
|
1052
|
+
had happened: a header-only file never changes size, so a watcher that memoised it as corrupt
|
|
1053
|
+
blocked the receipts fallback -- the one that makes deleting a feed cost nothing but the show --
|
|
1054
|
+
for the rest of the server's life.
|
|
1055
|
+
"""
|
|
1056
|
+
|
|
1057
|
+
return _read_feed_parts(path)[0]
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _read_feed_parts(path: str | os.PathLike[str]) -> tuple[bool, list[dict[str, Any]]]:
|
|
1061
|
+
"""``(these bytes are one of our feeds, the records they carry)``. Never raises."""
|
|
1062
|
+
|
|
1063
|
+
# ONE open, through the descriptor-checked helper. This used to be `stat`, then
|
|
1064
|
+
# `os.path.isfile`, then `Path.read_bytes` -- three separate looks at one name, the last of them
|
|
1065
|
+
# a blocking open. A local user swapping a FIFO in between the check and the read parked the
|
|
1066
|
+
# watcher's poll thread forever, four times a second's worth of chances to win the race, while
|
|
1067
|
+
# the server kept serving a snapshot that could never change again.
|
|
1068
|
+
try:
|
|
1069
|
+
payload = read_regular_bytes(Path(path), max_bytes=MAX_FEED_BYTES)
|
|
1070
|
+
except OSError:
|
|
1071
|
+
return False, []
|
|
1072
|
+
|
|
1073
|
+
text = payload.decode("utf-8", "replace")
|
|
1074
|
+
if not text:
|
|
1075
|
+
return False, []
|
|
1076
|
+
if text.endswith("\n"):
|
|
1077
|
+
lines = text[:-1].split("\n")
|
|
1078
|
+
else:
|
|
1079
|
+
# The final line has no newline: it is torn, and the whole recovery rule is to drop it.
|
|
1080
|
+
lines = text.split("\n")[:-1]
|
|
1081
|
+
if not lines:
|
|
1082
|
+
return False, []
|
|
1083
|
+
|
|
1084
|
+
if not _header_is_ours(lines[0]):
|
|
1085
|
+
return False, []
|
|
1086
|
+
|
|
1087
|
+
records: list[dict[str, Any]] = []
|
|
1088
|
+
for line in lines[1:]:
|
|
1089
|
+
if len(records) >= MAX_FEED_RECORDS:
|
|
1090
|
+
break
|
|
1091
|
+
record = _read_record(line)
|
|
1092
|
+
if record is not None:
|
|
1093
|
+
records.append(record)
|
|
1094
|
+
return True, records
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def select_narrated_feed(
|
|
1098
|
+
feed_dir: str | os.PathLike[str],
|
|
1099
|
+
*,
|
|
1100
|
+
sealed_candidate_digest: str | None = None,
|
|
1101
|
+
) -> tuple[Path | None, int, list[dict[str, Any]]]:
|
|
1102
|
+
"""Select one attempt as ``(path, size, records)``.
|
|
1103
|
+
|
|
1104
|
+
A matching sealed digest wins. Otherwise, finished attempts that did not produce the sealed
|
|
1105
|
+
Build are ignored, and the newest nonempty active attempt is selected. At most
|
|
1106
|
+
``MAX_FEED_ATTEMPTS`` nonempty files are considered. If only an unreadable or empty file exists,
|
|
1107
|
+
the newest path is returned with no records so the caller can report it.
|
|
1108
|
+
"""
|
|
1109
|
+
|
|
1110
|
+
found: list[tuple[float, str, int]] = []
|
|
1111
|
+
newest_empty: tuple[float, str, int] | None = None
|
|
1112
|
+
try:
|
|
1113
|
+
with os.scandir(feed_dir) as entries:
|
|
1114
|
+
for entry in entries:
|
|
1115
|
+
if not entry.name.endswith(".jsonl"):
|
|
1116
|
+
continue
|
|
1117
|
+
try:
|
|
1118
|
+
# follow_symlinks=False: another local user may have pointed a name here.
|
|
1119
|
+
info = entry.stat(follow_symlinks=False)
|
|
1120
|
+
except OSError:
|
|
1121
|
+
continue
|
|
1122
|
+
if not stat.S_ISREG(info.st_mode):
|
|
1123
|
+
# THIS IS A SORT, NOT A GUARD. It picks which names are worth reading and
|
|
1124
|
+
# gathers the mtime they are ordered by; it says nothing about what the name
|
|
1125
|
+
# will mean when `read_feed` below opens it. Reading it as protection is how
|
|
1126
|
+
# the blocking open under it went unnoticed -- the open itself is what has to
|
|
1127
|
+
# be safe, and `open_regular_file` is what makes it so.
|
|
1128
|
+
continue
|
|
1129
|
+
candidate = (info.st_mtime, entry.name, info.st_size)
|
|
1130
|
+
if info.st_size == _empty_attempt_size(entry.name):
|
|
1131
|
+
# Empty no-op invocations do not use nonempty-attempt slots, so idempotent
|
|
1132
|
+
# re-runs cannot evict the attempt that sealed the candidate.
|
|
1133
|
+
if newest_empty is None or candidate > newest_empty:
|
|
1134
|
+
newest_empty = candidate
|
|
1135
|
+
continue
|
|
1136
|
+
# Bound the set while scanning. A min-heap of ``MAX_FEED_ATTEMPTS`` keeps the newest
|
|
1137
|
+
# files regardless of the order the directory
|
|
1138
|
+
# yields its names in, so neither the list nor the reads below it grow with the
|
|
1139
|
+
# number of files somebody put here. Appending first and truncating after would
|
|
1140
|
+
# have bounded the reads and not the scan.
|
|
1141
|
+
if len(found) < MAX_FEED_ATTEMPTS:
|
|
1142
|
+
heapq.heappush(found, candidate)
|
|
1143
|
+
else:
|
|
1144
|
+
heapq.heappushpop(found, candidate)
|
|
1145
|
+
except OSError:
|
|
1146
|
+
return None, 0, []
|
|
1147
|
+
if not found and newest_empty is None:
|
|
1148
|
+
return None, 0, []
|
|
1149
|
+
|
|
1150
|
+
found.sort(reverse=True)
|
|
1151
|
+
base = Path(feed_dir)
|
|
1152
|
+
narrated: tuple[Path, int, list[dict[str, Any]]] | None = None
|
|
1153
|
+
narrating: tuple[Path, int, list[dict[str, Any]]] | None = None
|
|
1154
|
+
matching: tuple[Path, int, list[dict[str, Any]]] | None = None
|
|
1155
|
+
narrating_precedes_match = False
|
|
1156
|
+
for _mtime, name, size in found:
|
|
1157
|
+
records = read_feed(base / name)
|
|
1158
|
+
if not records:
|
|
1159
|
+
continue
|
|
1160
|
+
candidate = (base / name, size, records)
|
|
1161
|
+
if (
|
|
1162
|
+
matching is None
|
|
1163
|
+
and sealed_candidate_digest is not None
|
|
1164
|
+
and _sealed(records) == sealed_candidate_digest
|
|
1165
|
+
):
|
|
1166
|
+
matching = candidate
|
|
1167
|
+
narrating_precedes_match = narrating is not None
|
|
1168
|
+
if narrated is None:
|
|
1169
|
+
narrated = candidate
|
|
1170
|
+
if narrating is None and not _finished(records):
|
|
1171
|
+
narrating = candidate
|
|
1172
|
+
if sealed_candidate_digest is not None:
|
|
1173
|
+
# A newer unfinished attempt is a retry narrating work after the retained matching seal.
|
|
1174
|
+
# Return it so the consumer can validate its own ``run_started`` and enforce monotonic
|
|
1175
|
+
# attempt replacement. Otherwise the matching seal remains the candidate's narration.
|
|
1176
|
+
if narrating is not None and (matching is None or narrating_precedes_match):
|
|
1177
|
+
return narrating
|
|
1178
|
+
return matching if matching is not None else (None, 0, [])
|
|
1179
|
+
if narrated is not None:
|
|
1180
|
+
return narrated
|
|
1181
|
+
if found:
|
|
1182
|
+
_mtime, name, size = found[0]
|
|
1183
|
+
else:
|
|
1184
|
+
assert newest_empty is not None
|
|
1185
|
+
_mtime, name, size = newest_empty
|
|
1186
|
+
return base / name, size, []
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _finished(records: Sequence[Mapping[str, Any]]) -> bool:
|
|
1190
|
+
"""Return whether this attempt sealed, stopped, or closed a sidecar-only invocation."""
|
|
1191
|
+
|
|
1192
|
+
if _sealed(records) is not None:
|
|
1193
|
+
return True
|
|
1194
|
+
return records[-1].get("event") in (TERMINAL_EVENT_NAMES | NOTEBOOK_SIDECAR_OUTCOME_EVENT_NAMES)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _sealed(records: Sequence[Mapping[str, Any]]) -> str | None:
|
|
1198
|
+
"""The candidate digest this attempt sealed, or ``None`` if it sealed nothing."""
|
|
1199
|
+
|
|
1200
|
+
for record in reversed(records):
|
|
1201
|
+
if record.get("event") != "build_sealed":
|
|
1202
|
+
continue
|
|
1203
|
+
facts = record.get("facts")
|
|
1204
|
+
digest = facts.get("candidate_digest") if isinstance(facts, Mapping) else None
|
|
1205
|
+
return digest if isinstance(digest, str) else None
|
|
1206
|
+
return None
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _header_is_ours(line: str) -> bool:
|
|
1210
|
+
try:
|
|
1211
|
+
header = json.loads(line)
|
|
1212
|
+
except (ValueError, RecursionError):
|
|
1213
|
+
return False
|
|
1214
|
+
return (
|
|
1215
|
+
isinstance(header, dict)
|
|
1216
|
+
and header.get("kind") == "header"
|
|
1217
|
+
and header.get("schema_version") == FEED_SCHEMA_VERSION
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _read_record(line: str) -> dict[str, Any] | None:
|
|
1222
|
+
if not line:
|
|
1223
|
+
return None
|
|
1224
|
+
try:
|
|
1225
|
+
parsed = json.loads(line)
|
|
1226
|
+
except (ValueError, RecursionError):
|
|
1227
|
+
return None
|
|
1228
|
+
if not isinstance(parsed, dict) or set(parsed) != set(_RECORD_KEYS):
|
|
1229
|
+
return None
|
|
1230
|
+
|
|
1231
|
+
seq = parsed["seq"]
|
|
1232
|
+
at = parsed["at"]
|
|
1233
|
+
event = parsed["event"]
|
|
1234
|
+
facts = parsed["facts"]
|
|
1235
|
+
evidence = parsed["evidence"]
|
|
1236
|
+
if isinstance(seq, bool) or not isinstance(seq, int):
|
|
1237
|
+
return None
|
|
1238
|
+
if isinstance(at, bool) or not isinstance(at, (int, float)):
|
|
1239
|
+
return None
|
|
1240
|
+
try:
|
|
1241
|
+
moment = float(at)
|
|
1242
|
+
except (OverflowError, ValueError):
|
|
1243
|
+
# Convert once and drop values that cannot be represented as floats.
|
|
1244
|
+
return None
|
|
1245
|
+
if not _EARLIEST_MOMENT <= moment <= _LATEST_MOMENT:
|
|
1246
|
+
# A MOMENT ON A CLOCK, not merely a finite float, and the difference between those two
|
|
1247
|
+
# questions is a permanent stall. `json.loads` accepts the bare tokens `Infinity`,
|
|
1248
|
+
# `-Infinity` and `NaN`, and it accepts `1.7e308` -- which is finite, is genuinely a float,
|
|
1249
|
+
# and is not a time. Every reader of this record subtracts one moment from another and
|
|
1250
|
+
# converts the difference to an integer: `watch._render_header` does it against the wall
|
|
1251
|
+
# clock, and `watch._render_log` does it against the FIRST record's moment, which is the
|
|
1252
|
+
# one that matters here. Two admitted records at opposite ends of the float range make
|
|
1253
|
+
# that subtraction overflow to `inf`, and `int(inf)` raises. A finiteness test cannot see
|
|
1254
|
+
# it, because both operands are finite; only a bound on the value can.
|
|
1255
|
+
#
|
|
1256
|
+
# However it arrives -- a token that is not a number, an integer no float can hold, or a
|
|
1257
|
+
# float no clock can name -- the cost used to be the whole view for the life of the
|
|
1258
|
+
# server: the watcher counted a failed poll, spliced the stall note onto the last good page
|
|
1259
|
+
# and never rendered another record, and no `except` downstream could give the page back
|
|
1260
|
+
# because the poisoned record was still in the feed on every subsequent read. `mr-data
|
|
1261
|
+
# fleet` had it worse: `scan_fleet` reads every child's feed through this same boundary and
|
|
1262
|
+
# catches only `OSError`, so one poisoned line in one run's feed ended the whole scan with a
|
|
1263
|
+
# traceback and no rows at all, for every other run in the directory.
|
|
1264
|
+
#
|
|
1265
|
+
# This is the boundary that exists to stop that. A record whose `at` is not a moment this
|
|
1266
|
+
# reader can carry is dropped here, the same way a record whose `at` is a string is dropped,
|
|
1267
|
+
# and the rest of the feed is read normally -- one bad line costs that line and never the
|
|
1268
|
+
# view. A guard in the renderers would have been a second place that has to know this rule,
|
|
1269
|
+
# and each renderer would have had to know it about PAIRS of records rather than about one.
|
|
1270
|
+
return None
|
|
1271
|
+
if not isinstance(event, str) or not isinstance(facts, dict):
|
|
1272
|
+
return None
|
|
1273
|
+
if evidence is not None:
|
|
1274
|
+
if not isinstance(evidence, dict) or set(evidence) != set(_EVIDENCE_KEYS):
|
|
1275
|
+
return None
|
|
1276
|
+
if not isinstance(evidence["member"], str) or not isinstance(evidence["sha256"], str):
|
|
1277
|
+
return None
|
|
1278
|
+
|
|
1279
|
+
clamped_facts = {
|
|
1280
|
+
# The KEY crosses the same boundary as the value. `json.loads` puts a lone surrogate in a
|
|
1281
|
+
# key as readily as in a string, and every surface that re-encodes the record encodes both.
|
|
1282
|
+
_clamp_text(key): _clamp(value)
|
|
1283
|
+
for key, value in list(facts.items())[:MAX_FACT_KEYS]
|
|
1284
|
+
if isinstance(key, str)
|
|
1285
|
+
}
|
|
1286
|
+
clamped_evidence = None if evidence is None else {k: _clamp(v) for k, v in evidence.items()}
|
|
1287
|
+
return {
|
|
1288
|
+
"seq": seq,
|
|
1289
|
+
"at": moment,
|
|
1290
|
+
"event": _clamp_text(event),
|
|
1291
|
+
"facts": clamped_facts,
|
|
1292
|
+
"evidence": clamped_evidence,
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def writable_text(value: str) -> str:
|
|
1297
|
+
"""Return UTF-8-encodable text, escaping lone surrogates as ASCII byte sequences.
|
|
1298
|
+
|
|
1299
|
+
Escaping occurs before length clamping so the result remains within the text ceiling.
|
|
1300
|
+
"""
|
|
1301
|
+
|
|
1302
|
+
try:
|
|
1303
|
+
value.encode("utf-8")
|
|
1304
|
+
except UnicodeEncodeError:
|
|
1305
|
+
return value.encode("utf-8", "backslashreplace").decode("ascii")
|
|
1306
|
+
return value
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def _clamp_text(value: str) -> str:
|
|
1310
|
+
value = writable_text(value)
|
|
1311
|
+
if len(value) <= MAX_TEXT_CHARS:
|
|
1312
|
+
return value
|
|
1313
|
+
return value[: MAX_TEXT_CHARS - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def _clamp(value: Any, depth: int = 0) -> Any:
|
|
1317
|
+
if isinstance(value, str):
|
|
1318
|
+
return _clamp_text(value)
|
|
1319
|
+
if depth >= _MAX_FACT_DEPTH:
|
|
1320
|
+
return None
|
|
1321
|
+
if isinstance(value, dict):
|
|
1322
|
+
return {
|
|
1323
|
+
_clamp_text(key): _clamp(inner, depth + 1)
|
|
1324
|
+
for key, inner in list(value.items())[:MAX_FACT_KEYS]
|
|
1325
|
+
if isinstance(key, str)
|
|
1326
|
+
}
|
|
1327
|
+
if isinstance(value, list):
|
|
1328
|
+
return [_clamp(item, depth + 1) for item in value[:_MAX_LIST_ITEMS]]
|
|
1329
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
1330
|
+
# A FACT VALUE THAT CANNOT BE WRITTEN DOWN AGAIN. `json.loads` accepts the bare tokens
|
|
1331
|
+
# `Infinity`, `-Infinity` and `NaN` anywhere a number may appear, and every surface that
|
|
1332
|
+
# hands a record on re-serializes it with `allow_nan=False`: `_dumps` here, and
|
|
1333
|
+
# `cli._emit`. `mr-data status --events` therefore died with an uncaught
|
|
1334
|
+
# `ValueError: Out of range float values are not JSON compliant: inf` -- `_main`'s except
|
|
1335
|
+
# tuple lists `json.JSONDecodeError` and not bare `ValueError`, so it left a traceback,
|
|
1336
|
+
# no output, and no records for ANY event. The record stays in the feed, so every later
|
|
1337
|
+
# invocation died the same way: one appended line cost that surface for good.
|
|
1338
|
+
#
|
|
1339
|
+
# The bound belongs here for the same reason the bound on `at` does. This is the one
|
|
1340
|
+
# boundary that decides what a record may carry, the writer already refuses these values
|
|
1341
|
+
# at the matching boundary (`_normalize_json` raises on a non-finite fact), and a
|
|
1342
|
+
# `try/except` at the CLI would have been a second place that has to know the rule --
|
|
1343
|
+
# one per surface, each of them a chance to miss it, as `fleet` and `render_page`
|
|
1344
|
+
# surviving this same line while `status --events` did not already showed.
|
|
1345
|
+
#
|
|
1346
|
+
# Nulled rather than dropping the whole record, because that is what every other bound in
|
|
1347
|
+
# this function does to a value it cannot carry: text over `MAX_TEXT_CHARS` is truncated,
|
|
1348
|
+
# keys past `MAX_FACT_KEYS` are left out, and a value nested past `_MAX_FACT_DEPTH`
|
|
1349
|
+
# already becomes exactly this `None`. `at` is structural -- every consumer subtracts it,
|
|
1350
|
+
# so a record without a moment is not a record -- while a fact is payload, and the seq,
|
|
1351
|
+
# moment and event name beside it are still true.
|
|
1352
|
+
return None
|
|
1353
|
+
return value
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
# --------------------------------------------------------------------------------------------
|
|
1357
|
+
# The byte-neutral instrumentation sink
|
|
1358
|
+
# --------------------------------------------------------------------------------------------
|
|
1359
|
+
|
|
1360
|
+
EventSink = Callable[[str, Mapping[str, Any], Mapping[str, Any] | None], None]
|
|
1361
|
+
|
|
1362
|
+
# Why a ContextVar and not a parameter: threading a sink through `build_candidate` ->
|
|
1363
|
+
# `_replay_candidate_derivations` -> `_create_and_seal_candidate` -> `verify_candidate` ->
|
|
1364
|
+
# `_read_sealed_candidate_tree` would change five signatures on the most security-sensitive path in
|
|
1365
|
+
# the tree, for a feature that emits no bytes into anything those functions produce. A context also
|
|
1366
|
+
# gives the isolation the hosted case needs: a thread starts from an empty or explicitly copied
|
|
1367
|
+
# context, so one worker's build can never write another worker's feed.
|
|
1368
|
+
_EVENTS: ContextVar[EventSink | None] = ContextVar("mostlyright_run_events", default=None)
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
def emit(
|
|
1372
|
+
event: str,
|
|
1373
|
+
facts: Mapping[str, Any],
|
|
1374
|
+
evidence: Mapping[str, Any] | None = None,
|
|
1375
|
+
) -> None:
|
|
1376
|
+
"""Emit one derived event. Reads nothing, hashes nothing, and returns nothing.
|
|
1377
|
+
|
|
1378
|
+
With no sink armed this is a ContextVar read and a return -- the build path pays nothing. The
|
|
1379
|
+
result of an ``emit`` reaches no member, no manifest and no digest (G1); a call site is a
|
|
1380
|
+
statement, never an expression whose value is used.
|
|
1381
|
+
|
|
1382
|
+
On ANY exception from the sink the sink is DISARMED for the rest of the arming context rather
|
|
1383
|
+
than retried. A failed event sink must not affect the Build.
|
|
1384
|
+
|
|
1385
|
+
A consequence worth knowing before debugging one: a producer whose facts drift from
|
|
1386
|
+
``EVENT_FACTS`` raises inside ``EventWriter.write``, which this function catches, which
|
|
1387
|
+
therefore disarms the feed for the rest of the run. That is deliberate -- a feed missing its
|
|
1388
|
+
tail is a loud failure, while a feed carrying a wrong fact set would be a quiet one -- but it
|
|
1389
|
+
means a drifted producer shows up as a TRUNCATED feed. Look for a ``validate_facts``
|
|
1390
|
+
``ValueError`` first.
|
|
1391
|
+
"""
|
|
1392
|
+
|
|
1393
|
+
sink = _EVENTS.get()
|
|
1394
|
+
if sink is None:
|
|
1395
|
+
return None
|
|
1396
|
+
try:
|
|
1397
|
+
sink(event, facts, evidence)
|
|
1398
|
+
except Exception:
|
|
1399
|
+
_EVENTS.set(None)
|
|
1400
|
+
return None
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
@contextlib.contextmanager
|
|
1404
|
+
def sink_disabled() -> Iterator[None]:
|
|
1405
|
+
"""Suppress events inside the block and restore the previous sink on exit.
|
|
1406
|
+
|
|
1407
|
+
This exists for the candidate verify path: it re-derives every member from the sealed bytes,
|
|
1408
|
+
and a re-derivation is not the build. Emitting it would double every parse, clean, join and
|
|
1409
|
+
check event in the feed -- the viewer would show the run happening twice.
|
|
1410
|
+
"""
|
|
1411
|
+
|
|
1412
|
+
token = _EVENTS.set(None)
|
|
1413
|
+
try:
|
|
1414
|
+
yield
|
|
1415
|
+
finally:
|
|
1416
|
+
_EVENTS.reset(token)
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
@contextlib.contextmanager
|
|
1420
|
+
def observer(watcher: EventSink) -> Iterator[None]:
|
|
1421
|
+
"""Arm ``watcher`` alongside whatever sink is already armed, for the duration of the block.
|
|
1422
|
+
|
|
1423
|
+
This is a SEAM, not a vocabulary change. It adds no name, no fact key and no record shape, and
|
|
1424
|
+
it cannot make an unsealed fact reach the feed: a watcher is only ever CALLED with records the
|
|
1425
|
+
build already emitted, and its return value is discarded. The seam exists so a second, avowedly
|
|
1426
|
+
unsealed consumer -- `progress_events`, whose module docstring states the boundary -- can watch
|
|
1427
|
+
a build in flight without either vocabulary learning about the other.
|
|
1428
|
+
|
|
1429
|
+
Composition, not replacement. The previous sink stays armed and is called FIRST, so a watcher
|
|
1430
|
+
can never cost the feed a record; a watcher that raises is caught here and disarms nothing,
|
|
1431
|
+
because ``emit`` disarms the whole sink on an exception and one broken watcher must not silence
|
|
1432
|
+
the feed. The pairing with ``sink_disabled`` is the one that matters on the verify path: a
|
|
1433
|
+
re-derivation suppresses the sink, so a watcher installed through this seam sees the build once,
|
|
1434
|
+
exactly like the feed does.
|
|
1435
|
+
"""
|
|
1436
|
+
|
|
1437
|
+
previous = _EVENTS.get()
|
|
1438
|
+
|
|
1439
|
+
def _both(
|
|
1440
|
+
event: str, facts: Mapping[str, Any], evidence: Mapping[str, Any] | None = None
|
|
1441
|
+
) -> None:
|
|
1442
|
+
if previous is not None:
|
|
1443
|
+
previous(event, facts, evidence)
|
|
1444
|
+
try:
|
|
1445
|
+
watcher(event, facts, evidence)
|
|
1446
|
+
except Exception:
|
|
1447
|
+
return None
|
|
1448
|
+
return None
|
|
1449
|
+
|
|
1450
|
+
token = _EVENTS.set(_both)
|
|
1451
|
+
try:
|
|
1452
|
+
yield
|
|
1453
|
+
finally:
|
|
1454
|
+
_EVENTS.reset(token)
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
@contextlib.contextmanager
|
|
1458
|
+
def event_sink(path: str | os.PathLike[str], *, producer_attempt: str) -> Iterator[EventWriter]:
|
|
1459
|
+
"""Arm the feed for the duration of one build attempt and close it with a terminal record.
|
|
1460
|
+
|
|
1461
|
+
Terminal-record rules -- this is the answer to "watching a failed build shows nothing":
|
|
1462
|
+
|
|
1463
|
+
* a clean exit writes nothing extra, because the build already emitted ``build_sealed``;
|
|
1464
|
+
* ``KeyboardInterrupt`` or ``SystemExit`` writes one ``run_interrupted``;
|
|
1465
|
+
* any other exception writes one ``build_failed`` carrying the exception's ``finding_id`` and
|
|
1466
|
+
``severity`` when it has them.
|
|
1467
|
+
|
|
1468
|
+
Those key sets are the ``EVENT_FACTS`` entries for the two terminal events, so the exit path is
|
|
1469
|
+
validated by the same spec as every other record. The context manager NEVER suppresses the
|
|
1470
|
+
exception, and every write on the exit path is wrapped so a failing feed cannot mask the real
|
|
1471
|
+
error.
|
|
1472
|
+
"""
|
|
1473
|
+
|
|
1474
|
+
writer = EventWriter(path, run=producer_attempt)
|
|
1475
|
+
|
|
1476
|
+
def _sink(
|
|
1477
|
+
event: str, facts: Mapping[str, Any], evidence: Mapping[str, Any] | None = None
|
|
1478
|
+
) -> None:
|
|
1479
|
+
"""Write one record; a record that named a place costs that record and nothing more.
|
|
1480
|
+
|
|
1481
|
+
``emit`` disarms the sink on any exception, which is right for a producer whose fact keys
|
|
1482
|
+
drifted and wrong for a fact that merely carried a path: one such string must not drop
|
|
1483
|
+
later records or the terminal record. The
|
|
1484
|
+
location refusal is handled HERE, before it can reach ``emit``: the record is rewritten
|
|
1485
|
+
with its offending tokens redacted, and if even that is refused the record alone is
|
|
1486
|
+
dropped. The sink stays armed either way.
|
|
1487
|
+
|
|
1488
|
+
Unwritable text takes the same route for the same reason. A filename carrying invalid
|
|
1489
|
+
UTF-8 reaches a fact through ``surrogateescape`` with no attacker anywhere -- the producer
|
|
1490
|
+
writes it itself -- so the value is repaired through ``writable_text`` and the record is
|
|
1491
|
+
kept. Only the string changes; the fact it states is unchanged.
|
|
1492
|
+
"""
|
|
1493
|
+
|
|
1494
|
+
try:
|
|
1495
|
+
writer.write(event, facts, evidence)
|
|
1496
|
+
except (PlaceNamedError, UnwritableTextError):
|
|
1497
|
+
# ONE retry carrying BOTH repairs, in the order that composes: text is made writable
|
|
1498
|
+
# first, then places are redacted out of it. Two separate retries would have dropped a
|
|
1499
|
+
# record that needed both -- and a message built from an undecodable filename is
|
|
1500
|
+
# exactly a message that also carries a path.
|
|
1501
|
+
try:
|
|
1502
|
+
writer.write(event, _redact_places(_repair_text(facts)), evidence)
|
|
1503
|
+
except ValueError:
|
|
1504
|
+
return None
|
|
1505
|
+
return None
|
|
1506
|
+
|
|
1507
|
+
token = _EVENTS.set(_sink)
|
|
1508
|
+
try:
|
|
1509
|
+
yield writer
|
|
1510
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1511
|
+
_write_terminal(writer, "run_interrupted", {})
|
|
1512
|
+
raise
|
|
1513
|
+
except BaseException as exc:
|
|
1514
|
+
finding_id = getattr(exc, "finding_id", None)
|
|
1515
|
+
_write_terminal(
|
|
1516
|
+
writer,
|
|
1517
|
+
"build_failed",
|
|
1518
|
+
{
|
|
1519
|
+
"finding_id": None if finding_id is None else _clamp_text(str(finding_id)),
|
|
1520
|
+
"severity": _clamp_text(str(getattr(exc, "severity", "blocker"))),
|
|
1521
|
+
"message": _clamp_text(str(exc)),
|
|
1522
|
+
},
|
|
1523
|
+
)
|
|
1524
|
+
raise
|
|
1525
|
+
finally:
|
|
1526
|
+
_EVENTS.reset(token)
|
|
1527
|
+
try:
|
|
1528
|
+
writer.close()
|
|
1529
|
+
except Exception: # pragma: no cover - closing a descriptor twice is the only shape here
|
|
1530
|
+
pass
|
|
1531
|
+
|
|
1532
|
+
|
|
1533
|
+
_WITHHELD = "withheld: it named a location"
|
|
1534
|
+
# What one refused token becomes. A whole message replaced by `_WITHHELD` reads as a hole; the
|
|
1535
|
+
# sentence around the path is the half a reader needs -- "output already exists: (a location on
|
|
1536
|
+
# this machine)" says what went wrong, and says it identically from every machine.
|
|
1537
|
+
_PLACE_PLACEHOLDER = "(a location on this machine)"
|
|
1538
|
+
|
|
1539
|
+
|
|
1540
|
+
def _redact_place_text(key: str, text: str) -> str:
|
|
1541
|
+
"""Return ``text`` with every token the location guard refuses replaced, or ``_WITHHELD``.
|
|
1542
|
+
|
|
1543
|
+
Token-wise, so the prose survives and only the place is lost. If what remains still names a
|
|
1544
|
+
place -- this host's name spelled inside a word, for instance -- the whole string is withheld
|
|
1545
|
+
rather than half-cleaned: a partial redaction that still leaks is worse than a blank.
|
|
1546
|
+
"""
|
|
1547
|
+
|
|
1548
|
+
rebuilt: list[str] = []
|
|
1549
|
+
for chunk in re.split(r"(\s+)", text):
|
|
1550
|
+
if not chunk or chunk.isspace():
|
|
1551
|
+
rebuilt.append(chunk)
|
|
1552
|
+
continue
|
|
1553
|
+
try:
|
|
1554
|
+
_require_placeless(key, chunk)
|
|
1555
|
+
except PlaceNamedError:
|
|
1556
|
+
rebuilt.append(_PLACE_PLACEHOLDER)
|
|
1557
|
+
else:
|
|
1558
|
+
rebuilt.append(chunk)
|
|
1559
|
+
result = "".join(rebuilt)
|
|
1560
|
+
try:
|
|
1561
|
+
_require_placeless(key, result)
|
|
1562
|
+
except PlaceNamedError:
|
|
1563
|
+
return _WITHHELD
|
|
1564
|
+
return _clamp_text(result)
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
def _repair_text(value: Any) -> Any:
|
|
1568
|
+
"""Recursively make every string reachable in a fact value one a surface can encode.
|
|
1569
|
+
|
|
1570
|
+
The mirror of ``_redact_places``, over the other refusal the writer owns. Keys are repaired
|
|
1571
|
+
too: a fact KEY carrying a lone surrogate poisons ``json.dumps`` exactly as a value does.
|
|
1572
|
+
"""
|
|
1573
|
+
|
|
1574
|
+
if isinstance(value, str):
|
|
1575
|
+
return writable_text(value)
|
|
1576
|
+
if isinstance(value, Mapping):
|
|
1577
|
+
return {
|
|
1578
|
+
(writable_text(k) if isinstance(k, str) else k): _repair_text(inner)
|
|
1579
|
+
for k, inner in value.items()
|
|
1580
|
+
}
|
|
1581
|
+
if isinstance(value, (list, tuple)):
|
|
1582
|
+
return [_repair_text(item) for item in value]
|
|
1583
|
+
return value
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
def _redact_places(value: Any, *, key: str = "facts") -> Any:
|
|
1587
|
+
"""Recursively redact every place-naming string reachable in a fact value."""
|
|
1588
|
+
|
|
1589
|
+
if isinstance(value, str):
|
|
1590
|
+
return _redact_place_text(key, value)
|
|
1591
|
+
if isinstance(value, Mapping):
|
|
1592
|
+
return {
|
|
1593
|
+
inner_key: _redact_places(inner, key=f"{key}.{inner_key}")
|
|
1594
|
+
for inner_key, inner in value.items()
|
|
1595
|
+
}
|
|
1596
|
+
if isinstance(value, (list, tuple)):
|
|
1597
|
+
return [_redact_places(item, key=f"{key}[]") for item in value]
|
|
1598
|
+
return value
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
def _write_terminal(writer: EventWriter, event: str, facts: Mapping[str, Any]) -> None:
|
|
1602
|
+
"""Write the one terminal record, redacting rather than losing it if the guard refuses.
|
|
1603
|
+
|
|
1604
|
+
An exception message routinely carries a path, and a build that failed is exactly the run a
|
|
1605
|
+
watcher most wants to see end. So the first attempt writes the message as it stands; if the
|
|
1606
|
+
location-blind guard refuses it, the second attempt writes the same record with the offending
|
|
1607
|
+
tokens replaced. Either way the feed ends with exactly one terminal record, and neither attempt
|
|
1608
|
+
can raise into the real exception on its way out.
|
|
1609
|
+
"""
|
|
1610
|
+
|
|
1611
|
+
attempts: tuple[Mapping[str, Any], ...] = (facts, _redact_places(facts))
|
|
1612
|
+
for attempt in attempts:
|
|
1613
|
+
try:
|
|
1614
|
+
writer.write(event, attempt)
|
|
1615
|
+
return
|
|
1616
|
+
except ValueError:
|
|
1617
|
+
continue
|
|
1618
|
+
except Exception:
|
|
1619
|
+
return
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
# --------------------------------------------------------------------------------------------
|
|
1623
|
+
# The post-hoc projection over a sealed run
|
|
1624
|
+
# --------------------------------------------------------------------------------------------
|
|
1625
|
+
#
|
|
1626
|
+
# `emit` writes records during a build. `project_sealed_run` derives the same ordered records from
|
|
1627
|
+
# sealed bytes. Both use `EVENT_FACTS` and `validate_facts`; derived records may differ only in
|
|
1628
|
+
# `at`.
|
|
1629
|
+
# A manifest and its evidence members are sufficient when no event feed exists.
|
|
1630
|
+
|
|
1631
|
+
|
|
1632
|
+
def project_sealed_run(
|
|
1633
|
+
manifest: Mapping[str, Any],
|
|
1634
|
+
member_bytes: Mapping[str, bytes],
|
|
1635
|
+
) -> list[dict[str, Any]]:
|
|
1636
|
+
"""Derive the ordered event feed for one finished run from its sealed bytes alone.
|
|
1637
|
+
|
|
1638
|
+
PURE. It takes the parsed ``manifest.json`` mapping and a mapping of candidate-relative member
|
|
1639
|
+
path to that member's bytes, both already in memory, and it imports nothing from ``pipeline``.
|
|
1640
|
+
A caller that has already done the verified read must be able to project without the candidate
|
|
1641
|
+
being read a second time, and a projector that opened files itself would become exactly the
|
|
1642
|
+
second reader this design refuses to grow.
|
|
1643
|
+
|
|
1644
|
+
Every evidence pointer resolves through ``manifest["members"]``, so a projected event physically
|
|
1645
|
+
cannot cite a member the manifest does not carry. Every record passes
|
|
1646
|
+
``validate_facts`` and the location-blind guard before it is returned.
|
|
1647
|
+
|
|
1648
|
+
``at`` is ``0.0`` on every record. A projection has no clock, and a clock it invented would be a
|
|
1649
|
+
timestamp claiming to be an observation.
|
|
1650
|
+
|
|
1651
|
+
``build_failed`` and ``run_interrupted`` are NEVER projected, and no branch here should ever add
|
|
1652
|
+
them: a sealed run did not fail, and a run that was interrupted never sealed a manifest to
|
|
1653
|
+
project from. Those two events exist only on the live path.
|
|
1654
|
+
|
|
1655
|
+
Raises ``ValueError`` when the manifest's member list is malformed, when a member the projection
|
|
1656
|
+
needs was not supplied, or when a supplied member is not the JSON this projector was written
|
|
1657
|
+
against. It refuses rather than returning a partial feed: a half-projection reads to a viewer
|
|
1658
|
+
exactly like a run that stopped half way.
|
|
1659
|
+
"""
|
|
1660
|
+
|
|
1661
|
+
members_by_path = _manifest_members_by_path(manifest)
|
|
1662
|
+
records: list[dict[str, Any]] = []
|
|
1663
|
+
|
|
1664
|
+
def add(
|
|
1665
|
+
event: str,
|
|
1666
|
+
facts: Mapping[str, Any],
|
|
1667
|
+
evidence: Mapping[str, Any] | None,
|
|
1668
|
+
) -> None:
|
|
1669
|
+
records.append(_projected_record(len(records) + 1, event, facts, evidence))
|
|
1670
|
+
|
|
1671
|
+
def pointer(member: str) -> dict[str, Any]:
|
|
1672
|
+
return _pointer(members_by_path, member)
|
|
1673
|
+
|
|
1674
|
+
plan = _member_json(member_bytes, "plan.json")
|
|
1675
|
+
source_ids = [source["id"] for source in plan["sources"]]
|
|
1676
|
+
|
|
1677
|
+
add(
|
|
1678
|
+
"run_started",
|
|
1679
|
+
{
|
|
1680
|
+
"sources": list(source_ids),
|
|
1681
|
+
"output_intent": plan["output_intent"],
|
|
1682
|
+
"grain": list(plan["grain"]),
|
|
1683
|
+
"columns": list(plan["select"]),
|
|
1684
|
+
"evidence_member": EVIDENCE_MEMBER["run_started"],
|
|
1685
|
+
},
|
|
1686
|
+
pointer(EVIDENCE_MEMBER["run_started"]),
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
source_records = _member_json(member_bytes, EVIDENCE_MEMBER["rights_checked"])
|
|
1690
|
+
add(
|
|
1691
|
+
"rights_checked",
|
|
1692
|
+
{
|
|
1693
|
+
"sources": [
|
|
1694
|
+
{
|
|
1695
|
+
"source_id": record["source_id"],
|
|
1696
|
+
"status": record["rights"]["status"],
|
|
1697
|
+
"permissions": list(record["rights"]["permissions"]),
|
|
1698
|
+
}
|
|
1699
|
+
for record in source_records
|
|
1700
|
+
],
|
|
1701
|
+
"evidence_member": EVIDENCE_MEMBER["rights_checked"],
|
|
1702
|
+
},
|
|
1703
|
+
pointer(EVIDENCE_MEMBER["rights_checked"]),
|
|
1704
|
+
)
|
|
1705
|
+
|
|
1706
|
+
add(
|
|
1707
|
+
"sources_read_started",
|
|
1708
|
+
{"total": len(plan["sources"]), "evidence_member": EVIDENCE_MEMBER["sources_read_started"]},
|
|
1709
|
+
pointer(EVIDENCE_MEMBER["sources_read_started"]),
|
|
1710
|
+
)
|
|
1711
|
+
|
|
1712
|
+
for source_id in source_ids:
|
|
1713
|
+
member = source_member(source_id)
|
|
1714
|
+
add(
|
|
1715
|
+
"source_read",
|
|
1716
|
+
{
|
|
1717
|
+
"source_id": source_id,
|
|
1718
|
+
# From the MANIFEST, not from the bytes in hand: the manifest entry's `bytes` is
|
|
1719
|
+
# `len(member_bytes[...])` by construction at seal time, so this is the identical
|
|
1720
|
+
# integer the live emitter reports, and taking it from the receipt keeps this
|
|
1721
|
+
# function honest even when a caller supplied bytes it derived some other way.
|
|
1722
|
+
"bytes": _manifest_entry(members_by_path, member)["bytes"],
|
|
1723
|
+
"evidence_member": member,
|
|
1724
|
+
},
|
|
1725
|
+
pointer(member),
|
|
1726
|
+
)
|
|
1727
|
+
|
|
1728
|
+
add(
|
|
1729
|
+
"sources_parsed_started",
|
|
1730
|
+
{
|
|
1731
|
+
"total": len(plan["sources"]),
|
|
1732
|
+
"evidence_member": EVIDENCE_MEMBER["sources_parsed_started"],
|
|
1733
|
+
},
|
|
1734
|
+
pointer(EVIDENCE_MEMBER["sources_parsed_started"]),
|
|
1735
|
+
)
|
|
1736
|
+
|
|
1737
|
+
profiles = _member_json(member_bytes, EVIDENCE_MEMBER["source_parsed"])
|
|
1738
|
+
for source_id in source_ids:
|
|
1739
|
+
raw = profiles[source_id]["raw"]
|
|
1740
|
+
add(
|
|
1741
|
+
"source_parsed",
|
|
1742
|
+
{
|
|
1743
|
+
"source_id": source_id,
|
|
1744
|
+
"rows": raw["row_count"],
|
|
1745
|
+
"columns": len(raw["columns"]),
|
|
1746
|
+
"evidence_member": EVIDENCE_MEMBER["source_parsed"],
|
|
1747
|
+
},
|
|
1748
|
+
pointer(EVIDENCE_MEMBER["source_parsed"]),
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
# THE ONE CONDITIONAL RECORD. Live it fires only when
|
|
1752
|
+
# `package_context is not None and not allow_versioned_acquisition` -- offline runs, never
|
|
1753
|
+
# recipe runs and never plain builds. The sealed equivalent of that condition is BOTH halves
|
|
1754
|
+
# below: a recipe candidate seals `evidence/package.json` too, so the member's presence alone
|
|
1755
|
+
# would make the projection narrate a match the live feed never claimed, and the agreement test
|
|
1756
|
+
# would fail on precisely this branch.
|
|
1757
|
+
#
|
|
1758
|
+
# Both sides count over the PLAN's sources rather than over the proposal list, so a package
|
|
1759
|
+
# whose proposals are a superset of the plan cannot make the two disagree. The sealed key is
|
|
1760
|
+
# `evidence_sha256s`; `content_sha256` is the in-memory `SourceProposal` spelling of the same
|
|
1761
|
+
# values -- the same situation as `source`/`source_id` on `cleaning_applied`.
|
|
1762
|
+
matched_member = EVIDENCE_MEMBER["evidence_matched"]
|
|
1763
|
+
if (
|
|
1764
|
+
matched_member in members_by_path
|
|
1765
|
+
and manifest.get("manifest_version") != _RECIPE_MANIFEST_VERSION
|
|
1766
|
+
):
|
|
1767
|
+
package = _member_json(member_bytes, matched_member)
|
|
1768
|
+
cited_by_source = {
|
|
1769
|
+
proposal["source_id"]: set(proposal["evidence_sha256s"])
|
|
1770
|
+
for proposal in package["source_proposals"]
|
|
1771
|
+
}
|
|
1772
|
+
matched = sum(
|
|
1773
|
+
1
|
|
1774
|
+
for source_id in source_ids
|
|
1775
|
+
if _manifest_entry(members_by_path, source_member(source_id))["sha256"]
|
|
1776
|
+
in cited_by_source.get(source_id, frozenset())
|
|
1777
|
+
)
|
|
1778
|
+
add(
|
|
1779
|
+
"evidence_matched",
|
|
1780
|
+
{
|
|
1781
|
+
"matched": matched,
|
|
1782
|
+
"total": len(plan["sources"]),
|
|
1783
|
+
"evidence_member": matched_member,
|
|
1784
|
+
},
|
|
1785
|
+
pointer(matched_member),
|
|
1786
|
+
)
|
|
1787
|
+
|
|
1788
|
+
cleaning = plan["cleaning"]
|
|
1789
|
+
add(
|
|
1790
|
+
"cleaning_started",
|
|
1791
|
+
{"total": len(cleaning), "evidence_member": EVIDENCE_MEMBER["cleaning_started"]},
|
|
1792
|
+
pointer(EVIDENCE_MEMBER["cleaning_started"]),
|
|
1793
|
+
)
|
|
1794
|
+
for index, step in enumerate(cleaning, start=1):
|
|
1795
|
+
add(
|
|
1796
|
+
"cleaning_applied",
|
|
1797
|
+
{
|
|
1798
|
+
"step_index": index,
|
|
1799
|
+
"step_total": len(cleaning),
|
|
1800
|
+
# The sealed key is `source`; the parsed dataclass attribute the live emitter reads
|
|
1801
|
+
# is `source_id`. Same value, two spellings, and this is the sealed side.
|
|
1802
|
+
"source_id": step["source"],
|
|
1803
|
+
"operation": step["operation"],
|
|
1804
|
+
"evidence_member": EVIDENCE_MEMBER["cleaning_applied"],
|
|
1805
|
+
},
|
|
1806
|
+
pointer(EVIDENCE_MEMBER["cleaning_applied"]),
|
|
1807
|
+
)
|
|
1808
|
+
|
|
1809
|
+
# The `cleaned` stage of the same member `source_parsed` read the `raw` stage of. Cleaning may
|
|
1810
|
+
# add or drop columns, so this is the count AFTER the steps above, not before them.
|
|
1811
|
+
add(
|
|
1812
|
+
"sources_profiled",
|
|
1813
|
+
{
|
|
1814
|
+
"sources": len(plan["sources"]),
|
|
1815
|
+
"columns": sum(
|
|
1816
|
+
len(profiles[source_id]["cleaned"]["columns"]) for source_id in source_ids
|
|
1817
|
+
),
|
|
1818
|
+
"evidence_member": EVIDENCE_MEMBER["sources_profiled"],
|
|
1819
|
+
},
|
|
1820
|
+
pointer(EVIDENCE_MEMBER["sources_profiled"]),
|
|
1821
|
+
)
|
|
1822
|
+
|
|
1823
|
+
join = _member_json(member_bytes, EVIDENCE_MEMBER["join_completed"])
|
|
1824
|
+
join_facts = {
|
|
1825
|
+
key: join[key] for key in EVENT_FACTS["join_completed"] if key != "evidence_member"
|
|
1826
|
+
}
|
|
1827
|
+
join_facts["evidence_member"] = EVIDENCE_MEMBER["join_completed"]
|
|
1828
|
+
add("join_completed", join_facts, pointer(EVIDENCE_MEMBER["join_completed"]))
|
|
1829
|
+
|
|
1830
|
+
profile = _member_json(member_bytes, EVIDENCE_MEMBER["rows_selected"])
|
|
1831
|
+
add(
|
|
1832
|
+
"rows_selected",
|
|
1833
|
+
{
|
|
1834
|
+
"rows": profile["row_count"],
|
|
1835
|
+
"columns": len(plan["select"]),
|
|
1836
|
+
"evidence_member": EVIDENCE_MEMBER["rows_selected"],
|
|
1837
|
+
},
|
|
1838
|
+
pointer(EVIDENCE_MEMBER["rows_selected"]),
|
|
1839
|
+
)
|
|
1840
|
+
|
|
1841
|
+
quality = _member_json(member_bytes, EVIDENCE_MEMBER["checks_completed"])
|
|
1842
|
+
checks = quality["checks"]
|
|
1843
|
+
# The live emitter cannot count this member -- it does not exist yet -- so it computes the same
|
|
1844
|
+
# total from the plan: `2 + len(grain) + len(quality.not_null)`. The identity of those two
|
|
1845
|
+
# numbers is pinned by its own test, because a drift there fails the agreement test with a
|
|
1846
|
+
# message about a count rather than about the formula that produced it.
|
|
1847
|
+
add(
|
|
1848
|
+
"checks_started",
|
|
1849
|
+
{"total": len(checks), "evidence_member": EVIDENCE_MEMBER["checks_started"]},
|
|
1850
|
+
pointer(EVIDENCE_MEMBER["checks_started"]),
|
|
1851
|
+
)
|
|
1852
|
+
for index, check in enumerate(checks, start=1):
|
|
1853
|
+
add(
|
|
1854
|
+
"check_completed",
|
|
1855
|
+
{
|
|
1856
|
+
"check_id": check["check_id"],
|
|
1857
|
+
"index": index,
|
|
1858
|
+
"total": len(checks),
|
|
1859
|
+
"evidence_member": EVIDENCE_MEMBER["check_completed"],
|
|
1860
|
+
},
|
|
1861
|
+
pointer(EVIDENCE_MEMBER["check_completed"]),
|
|
1862
|
+
)
|
|
1863
|
+
add(
|
|
1864
|
+
"checks_completed",
|
|
1865
|
+
{
|
|
1866
|
+
# `passed` equals `total` on every sealed run, because `_validate_output` raises on the
|
|
1867
|
+
# first failed check. Counting rather than asserting is deliberate: the count is what
|
|
1868
|
+
# the member says, and a projector that hardcoded the equality would be asserting a
|
|
1869
|
+
# pipeline invariant instead of reading evidence.
|
|
1870
|
+
"passed": sum(1 for check in checks if check["passed"] is True),
|
|
1871
|
+
"total": len(checks),
|
|
1872
|
+
"evidence_member": EVIDENCE_MEMBER["checks_completed"],
|
|
1873
|
+
},
|
|
1874
|
+
pointer(EVIDENCE_MEMBER["checks_completed"]),
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
# `profile` was read above for `rows_selected`; both records state numbers out of the one member
|
|
1878
|
+
# that carries them.
|
|
1879
|
+
add(
|
|
1880
|
+
"table_written",
|
|
1881
|
+
{
|
|
1882
|
+
"rows": profile["row_count"],
|
|
1883
|
+
"columns": len(profile["columns"]),
|
|
1884
|
+
"evidence_member": EVIDENCE_MEMBER["table_written"],
|
|
1885
|
+
},
|
|
1886
|
+
pointer(EVIDENCE_MEMBER["table_written"]),
|
|
1887
|
+
)
|
|
1888
|
+
|
|
1889
|
+
add(
|
|
1890
|
+
"members_sealed_started",
|
|
1891
|
+
{
|
|
1892
|
+
"total": len(members_by_path),
|
|
1893
|
+
"evidence_member": EVIDENCE_MEMBER["members_sealed_started"],
|
|
1894
|
+
},
|
|
1895
|
+
pointer(EVIDENCE_MEMBER["members_sealed_started"]),
|
|
1896
|
+
)
|
|
1897
|
+
for member in sorted(members_by_path):
|
|
1898
|
+
entry = members_by_path[member]
|
|
1899
|
+
add(
|
|
1900
|
+
"member_sealed",
|
|
1901
|
+
{"bytes": entry["bytes"]},
|
|
1902
|
+
{"member": member, "sha256": entry["sha256"]},
|
|
1903
|
+
)
|
|
1904
|
+
|
|
1905
|
+
# A pure sum over the member table. Nothing here is hashed and nothing is opened: a fact is a
|
|
1906
|
+
# read of a value the seal already computed, which is what keeps the feed digest-neutral (G1).
|
|
1907
|
+
add(
|
|
1908
|
+
"candidate_installed",
|
|
1909
|
+
{
|
|
1910
|
+
"members": len(members_by_path),
|
|
1911
|
+
"bytes": sum(entry["bytes"] for entry in members_by_path.values()),
|
|
1912
|
+
"evidence_member": EVIDENCE_MEMBER["candidate_installed"],
|
|
1913
|
+
},
|
|
1914
|
+
pointer(EVIDENCE_MEMBER["candidate_installed"]),
|
|
1915
|
+
)
|
|
1916
|
+
|
|
1917
|
+
add(
|
|
1918
|
+
"snapshot_verify_started",
|
|
1919
|
+
{
|
|
1920
|
+
"total": len(members_by_path),
|
|
1921
|
+
"evidence_member": EVIDENCE_MEMBER["snapshot_verify_started"],
|
|
1922
|
+
},
|
|
1923
|
+
pointer(EVIDENCE_MEMBER["snapshot_verify_started"]),
|
|
1924
|
+
)
|
|
1925
|
+
# Sorted by member path, exactly as `member_sealed` is, so the two halves of the receipt read in
|
|
1926
|
+
# the same order on both producers.
|
|
1927
|
+
for member in sorted(members_by_path):
|
|
1928
|
+
entry = members_by_path[member]
|
|
1929
|
+
add(
|
|
1930
|
+
"member_verified",
|
|
1931
|
+
{"bytes": entry["bytes"]},
|
|
1932
|
+
{"member": member, "sha256": entry["sha256"]},
|
|
1933
|
+
)
|
|
1934
|
+
add(
|
|
1935
|
+
"snapshot_verified",
|
|
1936
|
+
{
|
|
1937
|
+
"members": len(members_by_path),
|
|
1938
|
+
"table_sha256": manifest["table_sha256"],
|
|
1939
|
+
"evidence_member": EVIDENCE_MEMBER["snapshot_verified"],
|
|
1940
|
+
},
|
|
1941
|
+
pointer(EVIDENCE_MEMBER["snapshot_verified"]),
|
|
1942
|
+
)
|
|
1943
|
+
|
|
1944
|
+
add(
|
|
1945
|
+
"build_sealed",
|
|
1946
|
+
{
|
|
1947
|
+
"candidate_digest": manifest["candidate_digest"],
|
|
1948
|
+
"table_sha256": manifest["table_sha256"],
|
|
1949
|
+
"manifest_version": manifest["manifest_version"],
|
|
1950
|
+
"row_count": profile["row_count"],
|
|
1951
|
+
},
|
|
1952
|
+
# The one record with no pointer: a candidate digest is not a member of anything.
|
|
1953
|
+
None,
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1956
|
+
return records
|
|
1957
|
+
|
|
1958
|
+
|
|
1959
|
+
def rederive_feed(
|
|
1960
|
+
run_dir: str | os.PathLike[str],
|
|
1961
|
+
*,
|
|
1962
|
+
pinned_candidate_fd: int | None = None,
|
|
1963
|
+
) -> list[dict[str, Any]]:
|
|
1964
|
+
"""Return a feed projected from a retained, verified sealed-run snapshot.
|
|
1965
|
+
|
|
1966
|
+
Three controls bind the projection to verified bytes:
|
|
1967
|
+
|
|
1968
|
+
* ``verify_candidate`` is called with ``_retained_leases``, so the descriptors it proved the
|
|
1969
|
+
run through stay OPEN across this whole function instead of closing at its return. Every
|
|
1970
|
+
member below is read relative to that retained candidate descriptor -- ``run_dir`` is never
|
|
1971
|
+
walked again, so the candidate directory cannot be replaced underneath the read.
|
|
1972
|
+
* The manifest is NOT re-read. ``VerifiedCandidate.manifest`` is the mapping the verify proved,
|
|
1973
|
+
so the byte counts and digests every member is checked against come from the proof rather
|
|
1974
|
+
than from a file an attacker may have rewritten since.
|
|
1975
|
+
* Each member's bytes are hashed and compared to that proved digest before anything is
|
|
1976
|
+
projected. A descriptor-relative open still resolves a NAME inside the pinned directory, so
|
|
1977
|
+
retention alone proves where the read went, not what it found; the digest is what proves the
|
|
1978
|
+
content. A single mismatch refuses the complete projection.
|
|
1979
|
+
|
|
1980
|
+
``lease.validate()`` then re-checks the retained roots at the linearization point the pipeline
|
|
1981
|
+
designed it for, and the lease is closed on every path out.
|
|
1982
|
+
|
|
1983
|
+
Retention pins the read but not the caller's expected subject. It starts at the
|
|
1984
|
+
``candidate`` name this call resolves, so the answer is about whichever directory sat at that
|
|
1985
|
+
name at that instant -- internally consistent, and not necessarily the run a long-lived reader
|
|
1986
|
+
thinks it is watching. A watcher that holds its own descriptor on the candidate and measures
|
|
1987
|
+
anything against it is therefore asking about one directory and being answered about another,
|
|
1988
|
+
``pinned_candidate_fd`` binds that subject: pass the expected descriptor, and verification of
|
|
1989
|
+
any other directory raises. Identity is
|
|
1990
|
+
compared through ``fstat`` on two OPEN descriptors, so neither can be renamed out from under
|
|
1991
|
+
the comparison, and an inode cannot be reused while a descriptor holds it -- equal ``(st_dev,
|
|
1992
|
+
st_ino)`` is the same directory, not a directory with the same name.
|
|
1993
|
+
|
|
1994
|
+
Raises whatever ``verify_candidate`` raises for a run that is missing, malformed or tampered,
|
|
1995
|
+
``ValueError`` for a member whose bytes are not the ones the manifest records or for a
|
|
1996
|
+
candidate that is not the pinned one, and ``OSError`` for a member that is absent or is not a
|
|
1997
|
+
regular file. It never returns an empty list for a run it could not read: a viewer renders an
|
|
1998
|
+
empty feed as "nothing happened", and "I could not verify this" is not "nothing happened".
|
|
1999
|
+
"""
|
|
2000
|
+
|
|
2001
|
+
# Deferred on purpose. `pipeline` imports this module for the emission seam, so a module-level
|
|
2002
|
+
# import here would close a cycle; it also keeps `events` loadable with zero harness
|
|
2003
|
+
# dependencies for the writer and reader paths, which is asserted by a test.
|
|
2004
|
+
from mostlyright.data_harness.pipeline import verify_candidate
|
|
2005
|
+
|
|
2006
|
+
run = Path(run_dir)
|
|
2007
|
+
leases: list[Any] = []
|
|
2008
|
+
verified = verify_candidate(run, _retained_leases=leases)
|
|
2009
|
+
if not leases: # pragma: no cover - developer invariant
|
|
2010
|
+
raise ValueError("the verify returned no retained candidate snapshot to project from")
|
|
2011
|
+
lease = leases[0]
|
|
2012
|
+
try:
|
|
2013
|
+
if pinned_candidate_fd is not None:
|
|
2014
|
+
_require_pinned_candidate(pinned_candidate_fd, lease.candidate_fd)
|
|
2015
|
+
manifest = verified.manifest
|
|
2016
|
+
member_bytes = _read_proved_members(lease.candidate_fd, manifest)
|
|
2017
|
+
lease.validate()
|
|
2018
|
+
finally:
|
|
2019
|
+
lease.close()
|
|
2020
|
+
return project_sealed_run(manifest, member_bytes)
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def _require_pinned_candidate(pinned_fd: int, proved_fd: int) -> None:
|
|
2024
|
+
"""Refuse a projection that is about a directory other than the caller's pinned candidate.
|
|
2025
|
+
|
|
2026
|
+
Checked BEFORE a single member is read, so a decoy's bytes are never loaded, let alone
|
|
2027
|
+
projected. Both arguments are open descriptors: ``fstat`` on an open descriptor answers about
|
|
2028
|
+
the object itself rather than about a name, so nothing here can be raced by a rename.
|
|
2029
|
+
"""
|
|
2030
|
+
|
|
2031
|
+
pinned = os.fstat(pinned_fd)
|
|
2032
|
+
proved = os.fstat(proved_fd)
|
|
2033
|
+
if (pinned.st_dev, pinned.st_ino) != (proved.st_dev, proved.st_ino):
|
|
2034
|
+
raise ValueError(
|
|
2035
|
+
"the candidate this run verified is not the candidate directory this reader pinned"
|
|
2036
|
+
)
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
def _read_proved_members(candidate_fd: int, manifest: Mapping[str, Any]) -> dict[str, bytes]:
|
|
2040
|
+
"""Read every member under the verify's own candidate descriptor, pinned to its proved digest.
|
|
2041
|
+
|
|
2042
|
+
``candidate_fd`` is the descriptor ``verify_candidate`` retained, so nothing here resolves a
|
|
2043
|
+
path from the run directory. The byte count and digest come from the manifest the verify
|
|
2044
|
+
proved. Any member that does not hash to it raises, and the caller has nothing to project.
|
|
2045
|
+
"""
|
|
2046
|
+
|
|
2047
|
+
members_by_path = _manifest_members_by_path(manifest)
|
|
2048
|
+
member_bytes: dict[str, bytes] = {}
|
|
2049
|
+
for member, entry in members_by_path.items():
|
|
2050
|
+
# The member paths were just verified, but they are still strings out of a file: resolving
|
|
2051
|
+
# one into a name without re-checking its shape is how a traversal gets a second chance.
|
|
2052
|
+
_require_member_path(member, "manifest member path")
|
|
2053
|
+
recorded_bytes = entry["bytes"]
|
|
2054
|
+
recorded_digest = entry["sha256"]
|
|
2055
|
+
if type(recorded_bytes) is not int or recorded_bytes < 0:
|
|
2056
|
+
raise ValueError(f"manifest member {member!r} records no byte count")
|
|
2057
|
+
if not isinstance(recorded_digest, str) or not _SHA256_RE.match(recorded_digest):
|
|
2058
|
+
raise ValueError(f"manifest member {member!r} records no sha256 digest")
|
|
2059
|
+
raw = read_regular_bytes(member, dir_fd=candidate_fd, max_bytes=recorded_bytes)
|
|
2060
|
+
if len(raw) != recorded_bytes or hashlib.sha256(raw).hexdigest() != recorded_digest:
|
|
2061
|
+
raise ValueError(f"member {member!r} is not the bytes this run's manifest records")
|
|
2062
|
+
member_bytes[member] = raw
|
|
2063
|
+
return member_bytes
|
|
2064
|
+
|
|
2065
|
+
|
|
2066
|
+
def _manifest_members_by_path(manifest: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
|
|
2067
|
+
"""Return the manifest's member table keyed by path, or raise ``ValueError``."""
|
|
2068
|
+
|
|
2069
|
+
if not isinstance(manifest, Mapping):
|
|
2070
|
+
raise ValueError("manifest is not a mapping")
|
|
2071
|
+
members = manifest.get("members")
|
|
2072
|
+
if not isinstance(members, list):
|
|
2073
|
+
raise ValueError("manifest carries no members list")
|
|
2074
|
+
by_path: dict[str, dict[str, Any]] = {}
|
|
2075
|
+
for entry in members:
|
|
2076
|
+
if not isinstance(entry, Mapping):
|
|
2077
|
+
raise ValueError(f"manifest member entry is not a mapping: {type(entry).__name__}")
|
|
2078
|
+
missing = [key for key in ("path", "bytes", "sha256") if key not in entry]
|
|
2079
|
+
if missing:
|
|
2080
|
+
raise ValueError(f"manifest member entry is missing {missing}")
|
|
2081
|
+
path = entry["path"]
|
|
2082
|
+
if not isinstance(path, str):
|
|
2083
|
+
raise ValueError(f"manifest member path is not a string: {path!r}")
|
|
2084
|
+
by_path[path] = dict(entry)
|
|
2085
|
+
if not by_path:
|
|
2086
|
+
raise ValueError("manifest carries no members")
|
|
2087
|
+
return by_path
|
|
2088
|
+
|
|
2089
|
+
|
|
2090
|
+
def _manifest_entry(
|
|
2091
|
+
members_by_path: Mapping[str, Mapping[str, Any]], member: str
|
|
2092
|
+
) -> Mapping[str, Any]:
|
|
2093
|
+
entry = members_by_path.get(member)
|
|
2094
|
+
if entry is None:
|
|
2095
|
+
raise ValueError(f"the manifest carries no member {member!r} for this event's evidence")
|
|
2096
|
+
return entry
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def _pointer(members_by_path: Mapping[str, Mapping[str, Any]], member: str) -> dict[str, Any]:
|
|
2100
|
+
"""Resolve one evidence pointer through the manifest, or raise.
|
|
2101
|
+
|
|
2102
|
+
The projection discharges its evidence-binding and portability invariants by construction: a
|
|
2103
|
+
projected event cannot name a member the manifest does not carry, and the digest it reports is
|
|
2104
|
+
the one the seal recorded, never one this function computed.
|
|
2105
|
+
"""
|
|
2106
|
+
|
|
2107
|
+
return {"member": member, "sha256": _manifest_entry(members_by_path, member)["sha256"]}
|
|
2108
|
+
|
|
2109
|
+
|
|
2110
|
+
def _projected_record(
|
|
2111
|
+
seq: int,
|
|
2112
|
+
event: str,
|
|
2113
|
+
facts: Mapping[str, Any],
|
|
2114
|
+
evidence: Mapping[str, Any] | None,
|
|
2115
|
+
) -> dict[str, Any]:
|
|
2116
|
+
"""Build one record through the same two gates the writer applies. One helper, one gate.
|
|
2117
|
+
|
|
2118
|
+
Nothing in ``project_sealed_run`` hand-checks a key set. A projector that checked its own keys
|
|
2119
|
+
inline would be a second opinion about the fact spec, and the point of `EVENT_FACTS` is that
|
|
2120
|
+
there is exactly one.
|
|
2121
|
+
"""
|
|
2122
|
+
|
|
2123
|
+
validate_facts(event, facts)
|
|
2124
|
+
record = {
|
|
2125
|
+
"seq": seq,
|
|
2126
|
+
"at": 0.0,
|
|
2127
|
+
"event": event,
|
|
2128
|
+
"facts": dict(facts),
|
|
2129
|
+
"evidence": None if evidence is None else dict(evidence),
|
|
2130
|
+
}
|
|
2131
|
+
_require_location_blind(record)
|
|
2132
|
+
return record
|
|
2133
|
+
|
|
2134
|
+
|
|
2135
|
+
def _member_json(member_bytes: Mapping[str, bytes], member: str) -> Any:
|
|
2136
|
+
"""Return one sealed member parsed as JSON, or raise ``ValueError`` naming the member."""
|
|
2137
|
+
|
|
2138
|
+
if not isinstance(member_bytes, Mapping):
|
|
2139
|
+
raise ValueError("member bytes is not a mapping")
|
|
2140
|
+
raw = member_bytes.get(member)
|
|
2141
|
+
if raw is None:
|
|
2142
|
+
raise ValueError(f"the projection needs member {member!r}, which was not supplied")
|
|
2143
|
+
if not isinstance(raw, (bytes, bytearray)):
|
|
2144
|
+
raise ValueError(f"member {member!r} was not supplied as bytes")
|
|
2145
|
+
return _decode_json(bytes(raw), member)
|
|
2146
|
+
|
|
2147
|
+
|
|
2148
|
+
def _decode_json(raw: bytes, member: str) -> Any:
|
|
2149
|
+
try:
|
|
2150
|
+
return json.loads(raw.decode("utf-8"))
|
|
2151
|
+
except (ValueError, RecursionError) as exc:
|
|
2152
|
+
raise ValueError(f"member {member!r} is not the JSON this projection expects") from exc
|