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,1067 @@
|
|
|
1
|
+
"""Content-addressed source authority prepared beside one immutable Recipe Build.
|
|
2
|
+
|
|
3
|
+
The Recipe candidate already seals the authoritative acquisition document. This module projects
|
|
4
|
+
each static source's acquired snapshot, receipt, and observation into content-addressed objects for
|
|
5
|
+
Studio source staging without inventing Studio artifact identifiers. Connector-backed sources bind
|
|
6
|
+
their exact executable adapter coordinate instead. A closed bundle manifest matches Studio's
|
|
7
|
+
reviewed-Build contract and binds the inventory to the candidate, Recipe, and execution.
|
|
8
|
+
|
|
9
|
+
Publication is manifest-last. Object files may exist without granting closure after an interrupted
|
|
10
|
+
write; only the digest-named bundle is a complete result. Every install is create-only and an
|
|
11
|
+
exact repeat converges on the existing bytes. Existing bytes, names, or file kinds that disagree
|
|
12
|
+
fail closed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import stat
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from mostlyright.data_harness import pipeline, recipe
|
|
28
|
+
from mostlyright.data_harness.canonical import (
|
|
29
|
+
CanonicalJSONError,
|
|
30
|
+
canonical_json_bytes,
|
|
31
|
+
parse_canonical_json,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
ACQUISITION_BUNDLE_SCHEMA = "mostlyright-deployment-acquisition-bundle.v2"
|
|
35
|
+
PERSISTED_ACQUISITION_BUNDLE_SCHEMA = "mostlyright-persisted-acquisition-bundle.v1"
|
|
36
|
+
ACQUISITION_STORE_RELATIVE = Path("evidence") / "acquisition"
|
|
37
|
+
MAX_OBJECT_BYTES = 4 * 1024 * 1024
|
|
38
|
+
MAX_SOURCE_OBJECT_BYTES = pipeline.MAX_SOURCE_BYTES
|
|
39
|
+
MAX_BUNDLE_BYTES = 4 * 1024 * 1024
|
|
40
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
41
|
+
_IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
|
|
42
|
+
_SEMVER = re.compile(r"^[1-9][0-9]*\.[0-9]+\.[0-9]+$")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DeploymentEvidenceError(RuntimeError):
|
|
46
|
+
"""The local acquisition-evidence projection is absent, changed, or unsafe."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
49
|
+
self.code = code
|
|
50
|
+
self.detail = detail
|
|
51
|
+
super().__init__(f"{code}: {detail}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class PersistedAcquisitionBundle:
|
|
56
|
+
bundle_digest: str
|
|
57
|
+
bundle_path: Path
|
|
58
|
+
candidate_digest: str
|
|
59
|
+
recipe_digest: str
|
|
60
|
+
execution_digest: str
|
|
61
|
+
source_count: int
|
|
62
|
+
object_count: int
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"schema_version": PERSISTED_ACQUISITION_BUNDLE_SCHEMA,
|
|
67
|
+
"bundle_digest": self.bundle_digest,
|
|
68
|
+
"bundle_path": str(self.bundle_path),
|
|
69
|
+
"candidate_digest": self.candidate_digest,
|
|
70
|
+
"recipe_digest": self.recipe_digest,
|
|
71
|
+
"execution_digest": self.execution_digest,
|
|
72
|
+
"source_count": self.source_count,
|
|
73
|
+
"object_count": self.object_count,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class AcquisitionSourcePayload:
|
|
79
|
+
"""One exact static source triplet retained from the authenticated local bundle."""
|
|
80
|
+
|
|
81
|
+
source: dict[str, Any]
|
|
82
|
+
source_data: bytes
|
|
83
|
+
acquisition_receipt: bytes
|
|
84
|
+
source_observation: bytes
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def persist_acquisition_bundle(
|
|
88
|
+
run_dir: Path,
|
|
89
|
+
*,
|
|
90
|
+
previous_run_dir: Path | None = None,
|
|
91
|
+
) -> PersistedAcquisitionBundle:
|
|
92
|
+
"""Project exact receipt/observation evidence and publish its closed bundle last."""
|
|
93
|
+
|
|
94
|
+
run = _safe_run_dir(run_dir)
|
|
95
|
+
handle = _open_run_handle(run)
|
|
96
|
+
store: _StoreDirectories | None = None
|
|
97
|
+
try:
|
|
98
|
+
store = _store_directories(run, handle.run_fd)
|
|
99
|
+
|
|
100
|
+
def validate_transaction() -> None:
|
|
101
|
+
_validate_run_handle(handle)
|
|
102
|
+
store.validate()
|
|
103
|
+
|
|
104
|
+
with pipeline.open_verified_snapshot(
|
|
105
|
+
run,
|
|
106
|
+
_run_fd=handle.run_fd,
|
|
107
|
+
_ancestor_validator=validate_transaction,
|
|
108
|
+
) as snapshot:
|
|
109
|
+
inspection, embedded = recipe.verify_recipe_candidate(
|
|
110
|
+
run,
|
|
111
|
+
previous_run_dir=None if previous_run_dir is None else Path(previous_run_dir),
|
|
112
|
+
_snapshot=snapshot,
|
|
113
|
+
)
|
|
114
|
+
acquisition = embedded["acquisition"]
|
|
115
|
+
document = _bundle_document(inspection, embedded, snapshot=snapshot)
|
|
116
|
+
raw_bundle = canonical_json_bytes(document)
|
|
117
|
+
bundle_digest = _sha256(raw_bundle)
|
|
118
|
+
|
|
119
|
+
acquisition_by_id = {source["source_id"]: source for source in acquisition["sources"]}
|
|
120
|
+
execution_by_id = {source.source_id: source for source in embedded["execution"].sources}
|
|
121
|
+
for source in document["sources"]:
|
|
122
|
+
if source["authority_kind"] != "sealed_artifact":
|
|
123
|
+
continue
|
|
124
|
+
source_id = source["source_id"]
|
|
125
|
+
sealed = acquisition_by_id[source_id]
|
|
126
|
+
if execution_by_id[source_id].artifact_input is None:
|
|
127
|
+
raise DeploymentEvidenceError(
|
|
128
|
+
"DEPLOYMENT_EVIDENCE_BINDING",
|
|
129
|
+
"a sealed source bundle entry has no artifact execution authority",
|
|
130
|
+
)
|
|
131
|
+
objects = {
|
|
132
|
+
"source_data": snapshot.member_bytes(f"raw/{source_id}.csv"),
|
|
133
|
+
"acquisition_receipt": canonical_json_bytes(sealed["acquisition_receipt"]),
|
|
134
|
+
"source_observation": canonical_json_bytes(sealed["observation"]),
|
|
135
|
+
}
|
|
136
|
+
for role, raw in objects.items():
|
|
137
|
+
validate_transaction()
|
|
138
|
+
digest = _sha256(raw)
|
|
139
|
+
suffix = ".bin" if role == "source_data" else ".json"
|
|
140
|
+
_install_exact(
|
|
141
|
+
store.objects / f"{digest}{suffix}",
|
|
142
|
+
raw,
|
|
143
|
+
maximum=(
|
|
144
|
+
MAX_SOURCE_OBJECT_BYTES if role == "source_data" else MAX_OBJECT_BYTES
|
|
145
|
+
),
|
|
146
|
+
directory_fd=store.objects_fd,
|
|
147
|
+
bindings=store.files,
|
|
148
|
+
)
|
|
149
|
+
validate_transaction()
|
|
150
|
+
|
|
151
|
+
bundle_path = store.bundles / f"{bundle_digest}.json"
|
|
152
|
+
validate_transaction()
|
|
153
|
+
_install_exact(
|
|
154
|
+
bundle_path,
|
|
155
|
+
raw_bundle,
|
|
156
|
+
maximum=MAX_BUNDLE_BYTES,
|
|
157
|
+
directory_fd=store.bundles_fd,
|
|
158
|
+
bindings=store.files,
|
|
159
|
+
)
|
|
160
|
+
validate_transaction()
|
|
161
|
+
snapshot.validate()
|
|
162
|
+
validate_transaction()
|
|
163
|
+
|
|
164
|
+
persisted = _parse_acquisition_bundle_bound(
|
|
165
|
+
run,
|
|
166
|
+
bundle_digest,
|
|
167
|
+
handle=handle,
|
|
168
|
+
store=store,
|
|
169
|
+
previous_run_dir=previous_run_dir,
|
|
170
|
+
)
|
|
171
|
+
if persisted.bundle_path != bundle_path:
|
|
172
|
+
raise DeploymentEvidenceError(
|
|
173
|
+
"DEPLOYMENT_EVIDENCE_PATH",
|
|
174
|
+
"the published bundle resolved to a different content-addressed path",
|
|
175
|
+
)
|
|
176
|
+
validate_transaction()
|
|
177
|
+
return persisted
|
|
178
|
+
finally:
|
|
179
|
+
try:
|
|
180
|
+
if store is not None:
|
|
181
|
+
store.close()
|
|
182
|
+
finally:
|
|
183
|
+
handle.close()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_acquisition_bundle(
|
|
187
|
+
run_dir: Path,
|
|
188
|
+
bundle_digest: str,
|
|
189
|
+
*,
|
|
190
|
+
previous_run_dir: Path | None = None,
|
|
191
|
+
) -> PersistedAcquisitionBundle:
|
|
192
|
+
"""Authenticate one published bundle against the exact sealed Recipe candidate."""
|
|
193
|
+
|
|
194
|
+
run = _safe_run_dir(run_dir)
|
|
195
|
+
handle = _open_run_handle(run)
|
|
196
|
+
store: _StoreDirectories | None = None
|
|
197
|
+
try:
|
|
198
|
+
store = _existing_store(run, handle.run_fd)
|
|
199
|
+
return _parse_acquisition_bundle_bound(
|
|
200
|
+
run,
|
|
201
|
+
bundle_digest,
|
|
202
|
+
handle=handle,
|
|
203
|
+
store=store,
|
|
204
|
+
previous_run_dir=previous_run_dir,
|
|
205
|
+
)
|
|
206
|
+
finally:
|
|
207
|
+
try:
|
|
208
|
+
if store is not None:
|
|
209
|
+
store.close()
|
|
210
|
+
finally:
|
|
211
|
+
handle.close()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def load_acquisition_bundle_document(
|
|
215
|
+
run_dir: Path,
|
|
216
|
+
bundle_digest: str,
|
|
217
|
+
*,
|
|
218
|
+
previous_run_dir: Path | None = None,
|
|
219
|
+
) -> dict[str, Any]:
|
|
220
|
+
"""Return the exact canonical bundle after authenticating it to the reviewed Build."""
|
|
221
|
+
|
|
222
|
+
run = _safe_run_dir(run_dir)
|
|
223
|
+
handle = _open_run_handle(run)
|
|
224
|
+
store: _StoreDirectories | None = None
|
|
225
|
+
try:
|
|
226
|
+
store = _existing_store(run, handle.run_fd)
|
|
227
|
+
persisted = _parse_acquisition_bundle_bound(
|
|
228
|
+
run,
|
|
229
|
+
bundle_digest,
|
|
230
|
+
handle=handle,
|
|
231
|
+
store=store,
|
|
232
|
+
previous_run_dir=previous_run_dir,
|
|
233
|
+
)
|
|
234
|
+
raw = _read_exact(
|
|
235
|
+
store.bundles / f"{persisted.bundle_digest}.json",
|
|
236
|
+
maximum=MAX_BUNDLE_BYTES,
|
|
237
|
+
directory_fd=store.bundles_fd,
|
|
238
|
+
bindings=store.files,
|
|
239
|
+
)
|
|
240
|
+
store.validate()
|
|
241
|
+
value = parse_canonical_json(raw)
|
|
242
|
+
if not isinstance(value, dict): # already guaranteed above; keep the public seam typed
|
|
243
|
+
raise DeploymentEvidenceError(
|
|
244
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "the acquisition bundle must be one object"
|
|
245
|
+
)
|
|
246
|
+
return dict(value)
|
|
247
|
+
finally:
|
|
248
|
+
try:
|
|
249
|
+
if store is not None:
|
|
250
|
+
store.close()
|
|
251
|
+
finally:
|
|
252
|
+
handle.close()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def load_acquisition_source_payload(
|
|
256
|
+
run_dir: Path,
|
|
257
|
+
bundle_digest: str,
|
|
258
|
+
source_id: str,
|
|
259
|
+
*,
|
|
260
|
+
previous_run_dir: Path | None = None,
|
|
261
|
+
) -> AcquisitionSourcePayload:
|
|
262
|
+
"""Return one static source triplet without releasing its bound store during reads."""
|
|
263
|
+
|
|
264
|
+
run = _safe_run_dir(run_dir)
|
|
265
|
+
handle = _open_run_handle(run)
|
|
266
|
+
store: _StoreDirectories | None = None
|
|
267
|
+
try:
|
|
268
|
+
store = _existing_store(run, handle.run_fd)
|
|
269
|
+
persisted = _parse_acquisition_bundle_bound(
|
|
270
|
+
run,
|
|
271
|
+
bundle_digest,
|
|
272
|
+
handle=handle,
|
|
273
|
+
store=store,
|
|
274
|
+
previous_run_dir=previous_run_dir,
|
|
275
|
+
)
|
|
276
|
+
raw_bundle = _read_exact(
|
|
277
|
+
store.bundles / f"{persisted.bundle_digest}.json",
|
|
278
|
+
maximum=MAX_BUNDLE_BYTES,
|
|
279
|
+
directory_fd=store.bundles_fd,
|
|
280
|
+
bindings=store.files,
|
|
281
|
+
)
|
|
282
|
+
value = parse_canonical_json(raw_bundle)
|
|
283
|
+
if not isinstance(value, dict) or not isinstance(value.get("sources"), list):
|
|
284
|
+
raise DeploymentEvidenceError(
|
|
285
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "the acquisition bundle source inventory is invalid"
|
|
286
|
+
)
|
|
287
|
+
matches = [
|
|
288
|
+
item
|
|
289
|
+
for item in value["sources"]
|
|
290
|
+
if isinstance(item, dict) and item.get("source_id") == source_id
|
|
291
|
+
]
|
|
292
|
+
if len(matches) != 1 or matches[0].get("authority_kind") != "sealed_artifact":
|
|
293
|
+
raise DeploymentEvidenceError(
|
|
294
|
+
"DEPLOYMENT_EVIDENCE_SOURCE",
|
|
295
|
+
f"the acquisition bundle does not contain one static source named {source_id}",
|
|
296
|
+
)
|
|
297
|
+
source = matches[0]
|
|
298
|
+
objects: dict[str, bytes] = {}
|
|
299
|
+
for role in ("source_data", "acquisition_receipt", "source_observation"):
|
|
300
|
+
reference = source[role]
|
|
301
|
+
digest = reference["content_sha256"]
|
|
302
|
+
suffix = ".bin" if role == "source_data" else ".json"
|
|
303
|
+
raw = _read_exact(
|
|
304
|
+
store.objects / f"{digest}{suffix}",
|
|
305
|
+
maximum=MAX_SOURCE_OBJECT_BYTES if role == "source_data" else MAX_OBJECT_BYTES,
|
|
306
|
+
directory_fd=store.objects_fd,
|
|
307
|
+
bindings=store.files,
|
|
308
|
+
)
|
|
309
|
+
if _sha256(raw) != digest or len(raw) != reference["size_bytes"]:
|
|
310
|
+
raise DeploymentEvidenceError(
|
|
311
|
+
"DEPLOYMENT_EVIDENCE_DIGEST",
|
|
312
|
+
f"the {role} object for {source_id} differs from its closed reference",
|
|
313
|
+
)
|
|
314
|
+
objects[role] = raw
|
|
315
|
+
store.validate()
|
|
316
|
+
return AcquisitionSourcePayload(
|
|
317
|
+
source=dict(source),
|
|
318
|
+
source_data=objects["source_data"],
|
|
319
|
+
acquisition_receipt=objects["acquisition_receipt"],
|
|
320
|
+
source_observation=objects["source_observation"],
|
|
321
|
+
)
|
|
322
|
+
finally:
|
|
323
|
+
try:
|
|
324
|
+
if store is not None:
|
|
325
|
+
store.close()
|
|
326
|
+
finally:
|
|
327
|
+
handle.close()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _parse_acquisition_bundle_bound(
|
|
331
|
+
run: Path,
|
|
332
|
+
bundle_digest: str,
|
|
333
|
+
*,
|
|
334
|
+
handle: Any,
|
|
335
|
+
store: _StoreDirectories,
|
|
336
|
+
previous_run_dir: Path | None,
|
|
337
|
+
) -> PersistedAcquisitionBundle:
|
|
338
|
+
digest = _digest(bundle_digest, "bundle digest")
|
|
339
|
+
bundle_path = store.bundles / f"{digest}.json"
|
|
340
|
+
store.validate()
|
|
341
|
+
raw = _read_exact(
|
|
342
|
+
bundle_path,
|
|
343
|
+
maximum=MAX_BUNDLE_BYTES,
|
|
344
|
+
directory_fd=store.bundles_fd,
|
|
345
|
+
bindings=store.files,
|
|
346
|
+
)
|
|
347
|
+
store.validate()
|
|
348
|
+
if _sha256(raw) != digest:
|
|
349
|
+
raise DeploymentEvidenceError(
|
|
350
|
+
"DEPLOYMENT_EVIDENCE_DIGEST",
|
|
351
|
+
"the acquisition bundle bytes do not match their content-addressed name",
|
|
352
|
+
)
|
|
353
|
+
try:
|
|
354
|
+
value = parse_canonical_json(raw)
|
|
355
|
+
except CanonicalJSONError as error:
|
|
356
|
+
raise DeploymentEvidenceError(
|
|
357
|
+
"DEPLOYMENT_EVIDENCE_CANONICAL",
|
|
358
|
+
"the acquisition bundle is not canonical JSON",
|
|
359
|
+
) from error
|
|
360
|
+
if not isinstance(value, dict):
|
|
361
|
+
raise DeploymentEvidenceError(
|
|
362
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "the acquisition bundle must be one object"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
def validate_transaction() -> None:
|
|
366
|
+
_validate_run_handle(handle)
|
|
367
|
+
store.validate()
|
|
368
|
+
|
|
369
|
+
with pipeline.open_verified_snapshot(
|
|
370
|
+
run,
|
|
371
|
+
_run_fd=handle.run_fd,
|
|
372
|
+
_ancestor_validator=validate_transaction,
|
|
373
|
+
) as snapshot:
|
|
374
|
+
inspection, embedded = recipe.verify_recipe_candidate(
|
|
375
|
+
run,
|
|
376
|
+
previous_run_dir=None if previous_run_dir is None else Path(previous_run_dir),
|
|
377
|
+
_snapshot=snapshot,
|
|
378
|
+
)
|
|
379
|
+
expected = _bundle_document(inspection, embedded, snapshot=snapshot)
|
|
380
|
+
_parse_bundle_shape(value)
|
|
381
|
+
if value != expected:
|
|
382
|
+
raise DeploymentEvidenceError(
|
|
383
|
+
"DEPLOYMENT_EVIDENCE_BINDING",
|
|
384
|
+
"the acquisition bundle does not match the exact sealed candidate evidence",
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
object_digests: set[str] = set()
|
|
388
|
+
expected_sources = {item["source_id"]: item for item in embedded["acquisition"]["sources"]}
|
|
389
|
+
for source in value["sources"]:
|
|
390
|
+
if source["authority_kind"] != "sealed_artifact":
|
|
391
|
+
continue
|
|
392
|
+
sealed = expected_sources[source["source_id"]]
|
|
393
|
+
expected_objects = {
|
|
394
|
+
"source_data": snapshot.member_bytes(f"raw/{source['source_id']}.csv"),
|
|
395
|
+
"acquisition_receipt": canonical_json_bytes(sealed["acquisition_receipt"]),
|
|
396
|
+
"source_observation": canonical_json_bytes(sealed["observation"]),
|
|
397
|
+
}
|
|
398
|
+
for role, expected_object in expected_objects.items():
|
|
399
|
+
validate_transaction()
|
|
400
|
+
reference = source[role]
|
|
401
|
+
object_digest = reference["content_sha256"]
|
|
402
|
+
suffix = ".bin" if role == "source_data" else ".json"
|
|
403
|
+
raw_object = _read_exact(
|
|
404
|
+
store.objects / f"{object_digest}{suffix}",
|
|
405
|
+
maximum=(
|
|
406
|
+
MAX_SOURCE_OBJECT_BYTES if role == "source_data" else MAX_OBJECT_BYTES
|
|
407
|
+
),
|
|
408
|
+
directory_fd=store.objects_fd,
|
|
409
|
+
bindings=store.files,
|
|
410
|
+
)
|
|
411
|
+
validate_transaction()
|
|
412
|
+
if _sha256(raw_object) != object_digest:
|
|
413
|
+
raise DeploymentEvidenceError(
|
|
414
|
+
"DEPLOYMENT_EVIDENCE_DIGEST",
|
|
415
|
+
f"the {role} object for {source['source_id']} mismatches its digest",
|
|
416
|
+
)
|
|
417
|
+
if raw_object != expected_object:
|
|
418
|
+
raise DeploymentEvidenceError(
|
|
419
|
+
"DEPLOYMENT_EVIDENCE_BINDING",
|
|
420
|
+
f"the {role} object for {source['source_id']} was spliced from "
|
|
421
|
+
"another source",
|
|
422
|
+
)
|
|
423
|
+
object_digests.add(object_digest)
|
|
424
|
+
snapshot.validate()
|
|
425
|
+
validate_transaction()
|
|
426
|
+
|
|
427
|
+
return PersistedAcquisitionBundle(
|
|
428
|
+
bundle_digest=digest,
|
|
429
|
+
bundle_path=bundle_path,
|
|
430
|
+
candidate_digest=value["candidate_digest"],
|
|
431
|
+
recipe_digest=value["recipe_digest"],
|
|
432
|
+
execution_digest=value["execution_digest"],
|
|
433
|
+
source_count=len(value["sources"]),
|
|
434
|
+
object_count=len(object_digests),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@dataclass
|
|
439
|
+
class _FileBinding:
|
|
440
|
+
directory_fd: int
|
|
441
|
+
name: str
|
|
442
|
+
descriptor: int
|
|
443
|
+
identity: tuple[int, int, int, int, int, int, int]
|
|
444
|
+
|
|
445
|
+
def validate(self) -> None:
|
|
446
|
+
try:
|
|
447
|
+
opened = os.fstat(self.descriptor)
|
|
448
|
+
named = os.stat(self.name, dir_fd=self.directory_fd, follow_symlinks=False)
|
|
449
|
+
except OSError as error:
|
|
450
|
+
raise DeploymentEvidenceError(
|
|
451
|
+
"DEPLOYMENT_EVIDENCE_RACE",
|
|
452
|
+
f"evidence object {self.name} changed during evidence handling",
|
|
453
|
+
) from error
|
|
454
|
+
if (
|
|
455
|
+
not _safe_regular_file(opened)
|
|
456
|
+
or not _safe_regular_file(named)
|
|
457
|
+
or _file_identity(opened) != self.identity
|
|
458
|
+
or _file_identity(named) != self.identity
|
|
459
|
+
):
|
|
460
|
+
raise DeploymentEvidenceError(
|
|
461
|
+
"DEPLOYMENT_EVIDENCE_RACE",
|
|
462
|
+
f"evidence object {self.name} changed during evidence handling",
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@dataclass
|
|
467
|
+
class _DirectoryBinding:
|
|
468
|
+
parent_fd: int
|
|
469
|
+
name: str
|
|
470
|
+
descriptor: int
|
|
471
|
+
device: int
|
|
472
|
+
inode: int
|
|
473
|
+
|
|
474
|
+
@classmethod
|
|
475
|
+
def capture(cls, parent_fd: int, name: str, descriptor: int) -> _DirectoryBinding:
|
|
476
|
+
opened = os.fstat(descriptor)
|
|
477
|
+
return cls(
|
|
478
|
+
parent_fd=parent_fd,
|
|
479
|
+
name=name,
|
|
480
|
+
descriptor=descriptor,
|
|
481
|
+
device=opened.st_dev,
|
|
482
|
+
inode=opened.st_ino,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def validate(self) -> None:
|
|
486
|
+
try:
|
|
487
|
+
opened = os.fstat(self.descriptor)
|
|
488
|
+
named = os.stat(self.name, dir_fd=self.parent_fd, follow_symlinks=False)
|
|
489
|
+
except OSError as error:
|
|
490
|
+
raise DeploymentEvidenceError(
|
|
491
|
+
"DEPLOYMENT_EVIDENCE_RACE",
|
|
492
|
+
f"evidence directory {self.name} changed during evidence handling",
|
|
493
|
+
) from error
|
|
494
|
+
expected = (self.device, self.inode)
|
|
495
|
+
if (
|
|
496
|
+
not stat.S_ISDIR(opened.st_mode)
|
|
497
|
+
or not stat.S_ISDIR(named.st_mode)
|
|
498
|
+
or (opened.st_dev, opened.st_ino) != expected
|
|
499
|
+
or (named.st_dev, named.st_ino) != expected
|
|
500
|
+
):
|
|
501
|
+
raise DeploymentEvidenceError(
|
|
502
|
+
"DEPLOYMENT_EVIDENCE_RACE",
|
|
503
|
+
f"evidence directory {self.name} changed during evidence handling",
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@dataclass
|
|
508
|
+
class _StoreDirectories:
|
|
509
|
+
root: Path
|
|
510
|
+
objects: Path
|
|
511
|
+
bundles: Path
|
|
512
|
+
objects_fd: int
|
|
513
|
+
bundles_fd: int
|
|
514
|
+
descriptors: list[int]
|
|
515
|
+
bindings: list[_DirectoryBinding]
|
|
516
|
+
files: list[_FileBinding]
|
|
517
|
+
|
|
518
|
+
def validate(self) -> None:
|
|
519
|
+
for binding in self.bindings:
|
|
520
|
+
binding.validate()
|
|
521
|
+
for file in self.files:
|
|
522
|
+
file.validate()
|
|
523
|
+
|
|
524
|
+
def close(self) -> None:
|
|
525
|
+
descriptors = list(reversed(self.descriptors))
|
|
526
|
+
self.descriptors.clear()
|
|
527
|
+
file_descriptors = [file.descriptor for file in reversed(self.files)]
|
|
528
|
+
self.files.clear()
|
|
529
|
+
first_error: OSError | None = None
|
|
530
|
+
for descriptor in file_descriptors + descriptors:
|
|
531
|
+
try:
|
|
532
|
+
os.close(descriptor)
|
|
533
|
+
except OSError as error:
|
|
534
|
+
if first_error is None:
|
|
535
|
+
first_error = error
|
|
536
|
+
if first_error is not None:
|
|
537
|
+
raise first_error
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _bundle_document(
|
|
541
|
+
inspection: pipeline.CandidateInspection,
|
|
542
|
+
embedded: Mapping[str, Any],
|
|
543
|
+
*,
|
|
544
|
+
snapshot: pipeline.VerifiedSnapshot,
|
|
545
|
+
) -> dict[str, Any]:
|
|
546
|
+
acquisition = embedded["acquisition"]
|
|
547
|
+
acquisition_by_id = {source["source_id"]: source for source in acquisition["sources"]}
|
|
548
|
+
recipe_by_id = {source.source_id: source for source in embedded["recipe"].sources}
|
|
549
|
+
execution_by_id = {source.source_id: source for source in embedded["execution"].sources}
|
|
550
|
+
source_ids = sorted(recipe_by_id)
|
|
551
|
+
if (
|
|
552
|
+
set(acquisition_by_id) != set(source_ids)
|
|
553
|
+
or set(execution_by_id) != set(source_ids)
|
|
554
|
+
or len(source_ids) != len(embedded["recipe"].sources)
|
|
555
|
+
):
|
|
556
|
+
raise DeploymentEvidenceError(
|
|
557
|
+
"DEPLOYMENT_EVIDENCE_BINDING",
|
|
558
|
+
"the Recipe, execution, and acquisition source inventories differ",
|
|
559
|
+
)
|
|
560
|
+
sources: list[dict[str, Any]] = []
|
|
561
|
+
for source_id in source_ids:
|
|
562
|
+
acquired = acquisition_by_id[source_id]
|
|
563
|
+
recipe_source = recipe_by_id[source_id]
|
|
564
|
+
execution_source = execution_by_id[source_id]
|
|
565
|
+
if execution_source.artifact_input is None:
|
|
566
|
+
sources.append(
|
|
567
|
+
{
|
|
568
|
+
"source_id": source_id,
|
|
569
|
+
"authority_kind": "connector",
|
|
570
|
+
"adapter_id": recipe_source.adapter_id,
|
|
571
|
+
"adapter_version": recipe_source.adapter_version,
|
|
572
|
+
}
|
|
573
|
+
)
|
|
574
|
+
continue
|
|
575
|
+
source_raw = snapshot.member_bytes(f"raw/{source_id}.csv")
|
|
576
|
+
receipt_raw = canonical_json_bytes(acquired["acquisition_receipt"])
|
|
577
|
+
observation_raw = canonical_json_bytes(acquired["observation"])
|
|
578
|
+
receipt = acquired["acquisition_receipt"]
|
|
579
|
+
receipt_snapshot = receipt.get("snapshot")
|
|
580
|
+
if receipt_snapshot is None and receipt.get("schema_version") == (
|
|
581
|
+
"recorded-artifact-receipt.v1"
|
|
582
|
+
):
|
|
583
|
+
# A sealed local artifact carries the flat recorded-artifact receipt the
|
|
584
|
+
# recipe executor writes, not a network acquisition's snapshot record.
|
|
585
|
+
# The binding it offers is the same one this bundle needs — the exact
|
|
586
|
+
# content digest of the sealed raw member — so verify that digest and
|
|
587
|
+
# take the media type from the sealed Recipe source, which is the
|
|
588
|
+
# authority that declared it. Every other shape still fails closed.
|
|
589
|
+
recipe_media_type = recipe_source.query_template.get("media_type")
|
|
590
|
+
if (
|
|
591
|
+
set(receipt) != {"schema_version", "source_id", "content_sha256"}
|
|
592
|
+
or receipt.get("source_id") != source_id
|
|
593
|
+
or receipt.get("content_sha256") != _sha256(source_raw)
|
|
594
|
+
or not isinstance(recipe_media_type, str)
|
|
595
|
+
or not recipe_media_type
|
|
596
|
+
):
|
|
597
|
+
raise DeploymentEvidenceError(
|
|
598
|
+
"DEPLOYMENT_EVIDENCE_BINDING",
|
|
599
|
+
f"the acquired snapshot for {source_id} differs from its receipt",
|
|
600
|
+
)
|
|
601
|
+
receipt_snapshot = {
|
|
602
|
+
"media_type": recipe_media_type,
|
|
603
|
+
"content_sha256": receipt["content_sha256"],
|
|
604
|
+
"size_bytes": len(source_raw),
|
|
605
|
+
}
|
|
606
|
+
if (
|
|
607
|
+
not isinstance(receipt_snapshot, dict)
|
|
608
|
+
or receipt_snapshot.get("content_sha256") != _sha256(source_raw)
|
|
609
|
+
or receipt_snapshot.get("size_bytes") != len(source_raw)
|
|
610
|
+
or not isinstance(receipt_snapshot.get("media_type"), str)
|
|
611
|
+
):
|
|
612
|
+
raise DeploymentEvidenceError(
|
|
613
|
+
"DEPLOYMENT_EVIDENCE_BINDING",
|
|
614
|
+
f"the acquired snapshot for {source_id} differs from its receipt",
|
|
615
|
+
)
|
|
616
|
+
sources.append(
|
|
617
|
+
{
|
|
618
|
+
"source_id": source_id,
|
|
619
|
+
"authority_kind": "sealed_artifact",
|
|
620
|
+
"source_data": {
|
|
621
|
+
"media_type": receipt_snapshot["media_type"],
|
|
622
|
+
"content_sha256": _sha256(source_raw),
|
|
623
|
+
"size_bytes": len(source_raw),
|
|
624
|
+
},
|
|
625
|
+
"acquisition_receipt": {
|
|
626
|
+
"media_type": "application/json",
|
|
627
|
+
"content_sha256": _sha256(receipt_raw),
|
|
628
|
+
"size_bytes": len(receipt_raw),
|
|
629
|
+
},
|
|
630
|
+
"source_observation": {
|
|
631
|
+
"media_type": "application/json",
|
|
632
|
+
"content_sha256": _sha256(observation_raw),
|
|
633
|
+
"size_bytes": len(observation_raw),
|
|
634
|
+
},
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
return {
|
|
638
|
+
"schema_version": ACQUISITION_BUNDLE_SCHEMA,
|
|
639
|
+
"candidate_digest": inspection.candidate_digest,
|
|
640
|
+
"recipe_digest": embedded["recipe"].digest,
|
|
641
|
+
"execution_digest": embedded["execution"].digest,
|
|
642
|
+
"source_count": len(sources),
|
|
643
|
+
"sources": sources,
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _parse_bundle_shape(value: Mapping[str, Any]) -> None:
|
|
648
|
+
expected = {
|
|
649
|
+
"schema_version",
|
|
650
|
+
"candidate_digest",
|
|
651
|
+
"recipe_digest",
|
|
652
|
+
"execution_digest",
|
|
653
|
+
"source_count",
|
|
654
|
+
"sources",
|
|
655
|
+
}
|
|
656
|
+
if set(value) != expected or value.get("schema_version") != ACQUISITION_BUNDLE_SCHEMA:
|
|
657
|
+
raise DeploymentEvidenceError(
|
|
658
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "the acquisition bundle fields are not exact"
|
|
659
|
+
)
|
|
660
|
+
for field in (
|
|
661
|
+
"candidate_digest",
|
|
662
|
+
"recipe_digest",
|
|
663
|
+
"execution_digest",
|
|
664
|
+
):
|
|
665
|
+
_digest(value.get(field), field)
|
|
666
|
+
sources = value.get("sources")
|
|
667
|
+
if not isinstance(sources, list) or not sources:
|
|
668
|
+
raise DeploymentEvidenceError(
|
|
669
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "the acquisition bundle source inventory is invalid"
|
|
670
|
+
)
|
|
671
|
+
if value.get("source_count") != len(sources):
|
|
672
|
+
raise DeploymentEvidenceError(
|
|
673
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "the acquisition bundle source count is invalid"
|
|
674
|
+
)
|
|
675
|
+
source_ids: list[str] = []
|
|
676
|
+
for source in sources:
|
|
677
|
+
if not isinstance(source, dict):
|
|
678
|
+
raise DeploymentEvidenceError(
|
|
679
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "an acquisition bundle source entry is invalid"
|
|
680
|
+
)
|
|
681
|
+
source_id = source.get("source_id")
|
|
682
|
+
if not isinstance(source_id, str) or _IDENTIFIER.fullmatch(source_id) is None:
|
|
683
|
+
raise DeploymentEvidenceError(
|
|
684
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "an acquisition bundle source id is invalid"
|
|
685
|
+
)
|
|
686
|
+
source_ids.append(source_id)
|
|
687
|
+
authority_kind = source.get("authority_kind")
|
|
688
|
+
if authority_kind == "connector":
|
|
689
|
+
if set(source) != {
|
|
690
|
+
"source_id",
|
|
691
|
+
"authority_kind",
|
|
692
|
+
"adapter_id",
|
|
693
|
+
"adapter_version",
|
|
694
|
+
} or any(
|
|
695
|
+
not isinstance(source.get(field), str) or pattern.fullmatch(source[field]) is None
|
|
696
|
+
for field, pattern in (("adapter_id", _IDENTIFIER), ("adapter_version", _SEMVER))
|
|
697
|
+
):
|
|
698
|
+
raise DeploymentEvidenceError(
|
|
699
|
+
"DEPLOYMENT_EVIDENCE_FIELDS",
|
|
700
|
+
"a connector acquisition bundle source entry is invalid",
|
|
701
|
+
)
|
|
702
|
+
continue
|
|
703
|
+
if authority_kind != "sealed_artifact" or set(source) != {
|
|
704
|
+
"source_id",
|
|
705
|
+
"authority_kind",
|
|
706
|
+
"source_data",
|
|
707
|
+
"acquisition_receipt",
|
|
708
|
+
"source_observation",
|
|
709
|
+
}:
|
|
710
|
+
raise DeploymentEvidenceError(
|
|
711
|
+
"DEPLOYMENT_EVIDENCE_FIELDS",
|
|
712
|
+
"a static acquisition bundle source entry is invalid",
|
|
713
|
+
)
|
|
714
|
+
for role in ("source_data", "acquisition_receipt", "source_observation"):
|
|
715
|
+
reference = source.get(role)
|
|
716
|
+
if not isinstance(reference, dict) or set(reference) != {
|
|
717
|
+
"media_type",
|
|
718
|
+
"content_sha256",
|
|
719
|
+
"size_bytes",
|
|
720
|
+
}:
|
|
721
|
+
raise DeploymentEvidenceError(
|
|
722
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", f"the {role} object reference is invalid"
|
|
723
|
+
)
|
|
724
|
+
if role != "source_data" and reference.get("media_type") != "application/json":
|
|
725
|
+
raise DeploymentEvidenceError(
|
|
726
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", f"the {role} media type is invalid"
|
|
727
|
+
)
|
|
728
|
+
_digest(reference.get("content_sha256"), f"{role} digest")
|
|
729
|
+
size = reference.get("size_bytes")
|
|
730
|
+
maximum = MAX_SOURCE_OBJECT_BYTES if role == "source_data" else MAX_OBJECT_BYTES
|
|
731
|
+
if type(size) is not int or not 1 <= size <= maximum:
|
|
732
|
+
raise DeploymentEvidenceError(
|
|
733
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", f"the {role} size is invalid"
|
|
734
|
+
)
|
|
735
|
+
if source_ids != sorted(set(source_ids)):
|
|
736
|
+
raise DeploymentEvidenceError(
|
|
737
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", "the acquisition bundle source ids are not sorted unique"
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _safe_run_dir(run_dir: Path) -> Path:
|
|
742
|
+
run = Path(run_dir).absolute()
|
|
743
|
+
if run == Path(run.anchor) or not run.name:
|
|
744
|
+
raise DeploymentEvidenceError(
|
|
745
|
+
"DEPLOYMENT_EVIDENCE_PATH", "the Recipe run path must name one non-root directory"
|
|
746
|
+
)
|
|
747
|
+
return run
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _open_run_handle(run_dir: Path) -> Any:
|
|
751
|
+
try:
|
|
752
|
+
return pipeline._open_candidate_run_handle(run_dir)
|
|
753
|
+
except pipeline.BuildError as error:
|
|
754
|
+
raise DeploymentEvidenceError(
|
|
755
|
+
"DEPLOYMENT_EVIDENCE_PATH", "the Recipe run directory is unavailable or unsafe"
|
|
756
|
+
) from error
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _validate_run_handle(handle: Any) -> None:
|
|
760
|
+
try:
|
|
761
|
+
pipeline._validate_candidate_run_handle(handle)
|
|
762
|
+
except pipeline.BuildError as error:
|
|
763
|
+
raise DeploymentEvidenceError(
|
|
764
|
+
"DEPLOYMENT_EVIDENCE_RACE", "the Recipe run path changed during evidence handling"
|
|
765
|
+
) from error
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _store_directories(run_dir: Path, run_fd: int) -> _StoreDirectories:
|
|
769
|
+
root = run_dir / ACQUISITION_STORE_RELATIVE
|
|
770
|
+
objects = root / "objects" / "sha256"
|
|
771
|
+
bundles = root / "bundles" / "sha256"
|
|
772
|
+
descriptors: list[int] = []
|
|
773
|
+
try:
|
|
774
|
+
evidence_fd = _open_directory_at(run_fd, "evidence", create=True)
|
|
775
|
+
descriptors.append(evidence_fd)
|
|
776
|
+
root_fd = _open_directory_at(evidence_fd, "acquisition", create=True)
|
|
777
|
+
descriptors.append(root_fd)
|
|
778
|
+
objects_parent_fd = _open_directory_at(root_fd, "objects", create=True)
|
|
779
|
+
descriptors.append(objects_parent_fd)
|
|
780
|
+
objects_fd = _open_directory_at(objects_parent_fd, "sha256", create=True)
|
|
781
|
+
descriptors.append(objects_fd)
|
|
782
|
+
bundles_parent_fd = _open_directory_at(root_fd, "bundles", create=True)
|
|
783
|
+
descriptors.append(bundles_parent_fd)
|
|
784
|
+
bundles_fd = _open_directory_at(bundles_parent_fd, "sha256", create=True)
|
|
785
|
+
descriptors.append(bundles_fd)
|
|
786
|
+
return _StoreDirectories(
|
|
787
|
+
root=root,
|
|
788
|
+
objects=objects,
|
|
789
|
+
bundles=bundles,
|
|
790
|
+
objects_fd=objects_fd,
|
|
791
|
+
bundles_fd=bundles_fd,
|
|
792
|
+
descriptors=descriptors,
|
|
793
|
+
bindings=_directory_bindings(run_fd, descriptors),
|
|
794
|
+
files=[],
|
|
795
|
+
)
|
|
796
|
+
except BaseException:
|
|
797
|
+
for descriptor in reversed(descriptors):
|
|
798
|
+
os.close(descriptor)
|
|
799
|
+
raise
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _existing_store(run_dir: Path, run_fd: int) -> _StoreDirectories:
|
|
803
|
+
root = run_dir / ACQUISITION_STORE_RELATIVE
|
|
804
|
+
descriptors: list[int] = []
|
|
805
|
+
try:
|
|
806
|
+
evidence_fd = _open_directory_at(run_fd, "evidence", create=False)
|
|
807
|
+
descriptors.append(evidence_fd)
|
|
808
|
+
root_fd = _open_directory_at(evidence_fd, "acquisition", create=False)
|
|
809
|
+
descriptors.append(root_fd)
|
|
810
|
+
objects_parent_fd = _open_directory_at(root_fd, "objects", create=False)
|
|
811
|
+
descriptors.append(objects_parent_fd)
|
|
812
|
+
objects_fd = _open_directory_at(objects_parent_fd, "sha256", create=False)
|
|
813
|
+
descriptors.append(objects_fd)
|
|
814
|
+
bundles_parent_fd = _open_directory_at(root_fd, "bundles", create=False)
|
|
815
|
+
descriptors.append(bundles_parent_fd)
|
|
816
|
+
bundles_fd = _open_directory_at(bundles_parent_fd, "sha256", create=False)
|
|
817
|
+
descriptors.append(bundles_fd)
|
|
818
|
+
return _StoreDirectories(
|
|
819
|
+
root=root,
|
|
820
|
+
objects=root / "objects" / "sha256",
|
|
821
|
+
bundles=root / "bundles" / "sha256",
|
|
822
|
+
objects_fd=objects_fd,
|
|
823
|
+
bundles_fd=bundles_fd,
|
|
824
|
+
descriptors=descriptors,
|
|
825
|
+
bindings=_directory_bindings(run_fd, descriptors),
|
|
826
|
+
files=[],
|
|
827
|
+
)
|
|
828
|
+
except BaseException:
|
|
829
|
+
for descriptor in reversed(descriptors):
|
|
830
|
+
os.close(descriptor)
|
|
831
|
+
raise
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _open_directory_at(parent_fd: int, name: str, *, create: bool) -> int:
|
|
835
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
836
|
+
if create:
|
|
837
|
+
try:
|
|
838
|
+
os.mkdir(name, mode=0o700, dir_fd=parent_fd)
|
|
839
|
+
except FileExistsError:
|
|
840
|
+
pass
|
|
841
|
+
except OSError as error:
|
|
842
|
+
raise DeploymentEvidenceError(
|
|
843
|
+
"DEPLOYMENT_EVIDENCE_PATH", f"could not create evidence directory {name}"
|
|
844
|
+
) from error
|
|
845
|
+
else:
|
|
846
|
+
os.fsync(parent_fd)
|
|
847
|
+
try:
|
|
848
|
+
descriptor = os.open(name, flags, dir_fd=parent_fd)
|
|
849
|
+
except OSError as error:
|
|
850
|
+
raise DeploymentEvidenceError(
|
|
851
|
+
"DEPLOYMENT_EVIDENCE_PATH", f"evidence directory {name} is unavailable or unsafe"
|
|
852
|
+
) from error
|
|
853
|
+
opened = os.fstat(descriptor)
|
|
854
|
+
if not stat.S_ISDIR(opened.st_mode):
|
|
855
|
+
os.close(descriptor)
|
|
856
|
+
raise DeploymentEvidenceError(
|
|
857
|
+
"DEPLOYMENT_EVIDENCE_PATH", f"evidence path {name} is not a real directory"
|
|
858
|
+
)
|
|
859
|
+
return descriptor
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _directory_bindings(run_fd: int, descriptors: list[int]) -> list[_DirectoryBinding]:
|
|
863
|
+
evidence_fd, root_fd, objects_parent_fd, objects_fd, bundles_parent_fd, bundles_fd = descriptors
|
|
864
|
+
return [
|
|
865
|
+
_DirectoryBinding.capture(run_fd, "evidence", evidence_fd),
|
|
866
|
+
_DirectoryBinding.capture(evidence_fd, "acquisition", root_fd),
|
|
867
|
+
_DirectoryBinding.capture(root_fd, "objects", objects_parent_fd),
|
|
868
|
+
_DirectoryBinding.capture(objects_parent_fd, "sha256", objects_fd),
|
|
869
|
+
_DirectoryBinding.capture(root_fd, "bundles", bundles_parent_fd),
|
|
870
|
+
_DirectoryBinding.capture(bundles_parent_fd, "sha256", bundles_fd),
|
|
871
|
+
]
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _install_exact(
|
|
875
|
+
path: Path,
|
|
876
|
+
raw: bytes,
|
|
877
|
+
*,
|
|
878
|
+
maximum: int,
|
|
879
|
+
directory_fd: int,
|
|
880
|
+
bindings: list[_FileBinding],
|
|
881
|
+
) -> bool:
|
|
882
|
+
if not 1 <= len(raw) <= maximum:
|
|
883
|
+
raise DeploymentEvidenceError(
|
|
884
|
+
"DEPLOYMENT_EVIDENCE_SIZE", f"evidence object {path.name} exceeds its byte limit"
|
|
885
|
+
)
|
|
886
|
+
try:
|
|
887
|
+
existing = _read_exact(
|
|
888
|
+
path,
|
|
889
|
+
maximum=maximum,
|
|
890
|
+
directory_fd=directory_fd,
|
|
891
|
+
bindings=bindings,
|
|
892
|
+
)
|
|
893
|
+
except DeploymentEvidenceError as error:
|
|
894
|
+
if error.code != "DEPLOYMENT_EVIDENCE_PATH":
|
|
895
|
+
raise
|
|
896
|
+
else:
|
|
897
|
+
if existing != raw:
|
|
898
|
+
raise DeploymentEvidenceError(
|
|
899
|
+
"DEPLOYMENT_EVIDENCE_CONFLICT",
|
|
900
|
+
f"existing content-addressed evidence {path.name} has different bytes",
|
|
901
|
+
)
|
|
902
|
+
return False
|
|
903
|
+
temporary = f".{path.name}.tmp-{uuid.uuid4().hex}"
|
|
904
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
|
|
905
|
+
descriptor = -1
|
|
906
|
+
try:
|
|
907
|
+
descriptor = os.open(temporary, flags, 0o600, dir_fd=directory_fd)
|
|
908
|
+
offset = 0
|
|
909
|
+
while offset < len(raw):
|
|
910
|
+
offset += os.write(descriptor, raw[offset:])
|
|
911
|
+
os.fsync(descriptor)
|
|
912
|
+
os.close(descriptor)
|
|
913
|
+
descriptor = -1
|
|
914
|
+
try:
|
|
915
|
+
os.link(
|
|
916
|
+
temporary,
|
|
917
|
+
path.name,
|
|
918
|
+
src_dir_fd=directory_fd,
|
|
919
|
+
dst_dir_fd=directory_fd,
|
|
920
|
+
follow_symlinks=False,
|
|
921
|
+
)
|
|
922
|
+
installed = True
|
|
923
|
+
except FileExistsError:
|
|
924
|
+
installed = False
|
|
925
|
+
if installed:
|
|
926
|
+
os.unlink(temporary, dir_fd=directory_fd)
|
|
927
|
+
if (
|
|
928
|
+
_read_exact(
|
|
929
|
+
path,
|
|
930
|
+
maximum=maximum,
|
|
931
|
+
directory_fd=directory_fd,
|
|
932
|
+
bindings=bindings,
|
|
933
|
+
)
|
|
934
|
+
!= raw
|
|
935
|
+
):
|
|
936
|
+
raise DeploymentEvidenceError(
|
|
937
|
+
"DEPLOYMENT_EVIDENCE_CONFLICT",
|
|
938
|
+
f"content-addressed evidence {path.name} did not converge on exact bytes",
|
|
939
|
+
)
|
|
940
|
+
os.fsync(directory_fd)
|
|
941
|
+
return installed
|
|
942
|
+
except DeploymentEvidenceError:
|
|
943
|
+
raise
|
|
944
|
+
except OSError as error:
|
|
945
|
+
raise DeploymentEvidenceError(
|
|
946
|
+
"DEPLOYMENT_EVIDENCE_WRITE", f"could not publish evidence object {path.name}"
|
|
947
|
+
) from error
|
|
948
|
+
finally:
|
|
949
|
+
if descriptor >= 0:
|
|
950
|
+
os.close(descriptor)
|
|
951
|
+
try:
|
|
952
|
+
os.unlink(temporary, dir_fd=directory_fd)
|
|
953
|
+
except FileNotFoundError:
|
|
954
|
+
pass
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _read_exact(
|
|
958
|
+
path: Path,
|
|
959
|
+
*,
|
|
960
|
+
maximum: int,
|
|
961
|
+
directory_fd: int,
|
|
962
|
+
bindings: list[_FileBinding],
|
|
963
|
+
) -> bytes:
|
|
964
|
+
flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
965
|
+
try:
|
|
966
|
+
descriptor = os.open(path.name, flags, dir_fd=directory_fd)
|
|
967
|
+
except OSError as error:
|
|
968
|
+
raise DeploymentEvidenceError(
|
|
969
|
+
"DEPLOYMENT_EVIDENCE_PATH", f"evidence object {path.name} is unavailable"
|
|
970
|
+
) from error
|
|
971
|
+
try:
|
|
972
|
+
before = os.fstat(descriptor)
|
|
973
|
+
named_before = _stat_named_file(path.name, directory_fd)
|
|
974
|
+
identity = _file_identity(before)
|
|
975
|
+
if (
|
|
976
|
+
not _safe_regular_file(before)
|
|
977
|
+
or not _safe_regular_file(named_before)
|
|
978
|
+
or _file_identity(named_before) != identity
|
|
979
|
+
or before.st_size > maximum
|
|
980
|
+
):
|
|
981
|
+
raise DeploymentEvidenceError(
|
|
982
|
+
"DEPLOYMENT_EVIDENCE_PATH", f"evidence object {path.name} is not a safe file"
|
|
983
|
+
)
|
|
984
|
+
raw = bytearray()
|
|
985
|
+
while len(raw) <= maximum:
|
|
986
|
+
chunk = os.read(descriptor, min(64 * 1024, maximum + 1 - len(raw)))
|
|
987
|
+
if not chunk:
|
|
988
|
+
break
|
|
989
|
+
raw.extend(chunk)
|
|
990
|
+
after = os.fstat(descriptor)
|
|
991
|
+
named_after = _stat_named_file(path.name, directory_fd)
|
|
992
|
+
if (
|
|
993
|
+
len(raw) > maximum
|
|
994
|
+
or not _safe_regular_file(after)
|
|
995
|
+
or not _safe_regular_file(named_after)
|
|
996
|
+
or _file_identity(after) != identity
|
|
997
|
+
or _file_identity(named_after) != identity
|
|
998
|
+
or len(raw) != before.st_size
|
|
999
|
+
):
|
|
1000
|
+
raise DeploymentEvidenceError(
|
|
1001
|
+
"DEPLOYMENT_EVIDENCE_RACE", f"evidence object {path.name} changed while read"
|
|
1002
|
+
)
|
|
1003
|
+
bindings.append(
|
|
1004
|
+
_FileBinding(
|
|
1005
|
+
directory_fd=directory_fd,
|
|
1006
|
+
name=path.name,
|
|
1007
|
+
descriptor=descriptor,
|
|
1008
|
+
identity=identity,
|
|
1009
|
+
)
|
|
1010
|
+
)
|
|
1011
|
+
descriptor = -1
|
|
1012
|
+
return bytes(raw)
|
|
1013
|
+
finally:
|
|
1014
|
+
if descriptor >= 0:
|
|
1015
|
+
os.close(descriptor)
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _safe_regular_file(value: os.stat_result) -> bool:
|
|
1019
|
+
return stat.S_ISREG(value.st_mode) and value.st_nlink == 1
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
def _stat_named_file(name: str, directory_fd: int) -> os.stat_result:
|
|
1023
|
+
try:
|
|
1024
|
+
return os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
1025
|
+
except OSError as error:
|
|
1026
|
+
raise DeploymentEvidenceError(
|
|
1027
|
+
"DEPLOYMENT_EVIDENCE_RACE",
|
|
1028
|
+
f"evidence object {name} changed during evidence handling",
|
|
1029
|
+
) from error
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _file_identity(value: os.stat_result) -> tuple[int, int, int, int, int, int, int]:
|
|
1033
|
+
return (
|
|
1034
|
+
value.st_dev,
|
|
1035
|
+
value.st_ino,
|
|
1036
|
+
value.st_mode,
|
|
1037
|
+
value.st_nlink,
|
|
1038
|
+
value.st_size,
|
|
1039
|
+
value.st_mtime_ns,
|
|
1040
|
+
value.st_ctime_ns,
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _digest(value: Any, label: str) -> str:
|
|
1045
|
+
if not isinstance(value, str) or _DIGEST.fullmatch(value) is None:
|
|
1046
|
+
raise DeploymentEvidenceError(
|
|
1047
|
+
"DEPLOYMENT_EVIDENCE_FIELDS", f"the {label} is not a lowercase SHA-256 digest"
|
|
1048
|
+
)
|
|
1049
|
+
return value
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def _sha256(raw: bytes) -> str:
|
|
1053
|
+
return hashlib.sha256(raw).hexdigest()
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
__all__ = [
|
|
1057
|
+
"ACQUISITION_BUNDLE_SCHEMA",
|
|
1058
|
+
"ACQUISITION_STORE_RELATIVE",
|
|
1059
|
+
"PERSISTED_ACQUISITION_BUNDLE_SCHEMA",
|
|
1060
|
+
"AcquisitionSourcePayload",
|
|
1061
|
+
"DeploymentEvidenceError",
|
|
1062
|
+
"PersistedAcquisitionBundle",
|
|
1063
|
+
"load_acquisition_bundle_document",
|
|
1064
|
+
"load_acquisition_source_payload",
|
|
1065
|
+
"parse_acquisition_bundle",
|
|
1066
|
+
"persist_acquisition_bundle",
|
|
1067
|
+
]
|