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,360 @@
|
|
|
1
|
+
"""Pure validation for generation-pinned hosted crawler result downloads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
from typing import Any, NoReturn
|
|
13
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from mostlyright.data_harness.hosted_crawler_protocol import (
|
|
17
|
+
CRAWLER_RESULT_MEDIA_TYPE,
|
|
18
|
+
MAX_CRAWLER_RESULT_BYTES,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
RESULT_DOWNLOAD_SCHEMA = "mostlyright-public-crawl-result-download.v1"
|
|
22
|
+
RESULT_MEDIA_TYPE = CRAWLER_RESULT_MEDIA_TYPE
|
|
23
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
24
|
+
_GENERATION = re.compile(r"^[1-9][0-9]{0,31}$")
|
|
25
|
+
_BUCKET = re.compile(r"^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$")
|
|
26
|
+
_LOCAL_SHA256 = re.compile(r"^sha256:[0-9a-f]{64}$")
|
|
27
|
+
_GCS_CRC32C = re.compile(r"^[A-Za-z0-9+/]{6}==$")
|
|
28
|
+
|
|
29
|
+
# The bounded unit a streaming caller transfers and verifies in. Sixty-four kibibytes is what the
|
|
30
|
+
# content-addressed snapshot verifier in `acquisition/http.py` already reads with, so the two
|
|
31
|
+
# streamed paths on this side of the boundary have one number between them.
|
|
32
|
+
RESULT_CHUNK_BYTES = 65_536
|
|
33
|
+
|
|
34
|
+
# CRC-32C (Castagnoli), reflected, initialised and finalised with 0xFFFFFFFF -- the exact function
|
|
35
|
+
# GCS reports as `crc32c`. The table is built once at import instead of once per verification: it
|
|
36
|
+
# is 256 constants derived from the polynomial and nothing about it varies per result.
|
|
37
|
+
_CRC32C_POLYNOMIAL = 0x82F63B78
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _build_crc32c_table() -> tuple[int, ...]:
|
|
41
|
+
table = []
|
|
42
|
+
for index in range(256):
|
|
43
|
+
value = index
|
|
44
|
+
for _ in range(8):
|
|
45
|
+
value = (value >> 1) ^ (_CRC32C_POLYNOMIAL if value & 1 else 0)
|
|
46
|
+
table.append(value)
|
|
47
|
+
return tuple(table)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_CRC32C_TABLE = _build_crc32c_table()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ResultDownloadError(ValueError):
|
|
54
|
+
"""One download authority or integrity invariant failed."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class ValidatedResultDownload:
|
|
59
|
+
url: str
|
|
60
|
+
size: int
|
|
61
|
+
media_type: str
|
|
62
|
+
integrity: Mapping[str, str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _refuse(detail: str) -> NoReturn:
|
|
66
|
+
"""Refuse, and say so in the signature.
|
|
67
|
+
|
|
68
|
+
``NoReturn`` is what makes "an integrity pin this module does not implement can never reach
|
|
69
|
+
the comparison" a property a checker can see rather than a sentence in a docstring: the
|
|
70
|
+
fall-through branch in :meth:`ResultBytesVerifier.verify` needs no dead ``return`` to prove
|
|
71
|
+
it, because the refusal is known not to come back.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
raise ResultDownloadError(detail)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _timestamp(value: Any, label: str) -> datetime:
|
|
78
|
+
if not isinstance(value, str) or not value.endswith("Z"):
|
|
79
|
+
_refuse(f"{label} is invalid")
|
|
80
|
+
try:
|
|
81
|
+
parsed = datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
|
82
|
+
except ValueError:
|
|
83
|
+
_refuse(f"{label} is invalid")
|
|
84
|
+
if parsed.tzinfo is None:
|
|
85
|
+
_refuse(f"{label} is invalid")
|
|
86
|
+
return parsed.astimezone(UTC)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _validate_transfer_url(
|
|
90
|
+
signed_url: Any,
|
|
91
|
+
*,
|
|
92
|
+
base_url: str,
|
|
93
|
+
crawler_session_id: str,
|
|
94
|
+
generation: str,
|
|
95
|
+
storage_authority: Any,
|
|
96
|
+
expected_gcs_bucket: str | None,
|
|
97
|
+
) -> str:
|
|
98
|
+
if not isinstance(signed_url, str) or not isinstance(storage_authority, Mapping):
|
|
99
|
+
_refuse("result transfer authority is invalid")
|
|
100
|
+
parsed = urlsplit(signed_url)
|
|
101
|
+
base = urlsplit(base_url)
|
|
102
|
+
try:
|
|
103
|
+
port = parsed.port
|
|
104
|
+
except ValueError:
|
|
105
|
+
_refuse("result transfer authority is invalid")
|
|
106
|
+
if parsed.username is not None or parsed.password is not None or parsed.fragment:
|
|
107
|
+
_refuse("result transfer authority is invalid")
|
|
108
|
+
if storage_authority.get("kind") == "gcs_bucket":
|
|
109
|
+
if set(storage_authority) != {"kind", "bucket"}:
|
|
110
|
+
_refuse("GCS storage authority is not exact")
|
|
111
|
+
bucket = storage_authority.get("bucket")
|
|
112
|
+
if not isinstance(bucket, str) or _BUCKET.fullmatch(bucket) is None:
|
|
113
|
+
_refuse("GCS storage authority is invalid")
|
|
114
|
+
if bucket != expected_gcs_bucket:
|
|
115
|
+
_refuse("GCS storage authority differs from the trusted deployment pin")
|
|
116
|
+
if (
|
|
117
|
+
parsed.scheme != "https"
|
|
118
|
+
or parsed.hostname != "storage.googleapis.com"
|
|
119
|
+
or port is not None
|
|
120
|
+
or "%" in parsed.path
|
|
121
|
+
or "\\" in parsed.path
|
|
122
|
+
):
|
|
123
|
+
_refuse("GCS result URL authority is invalid")
|
|
124
|
+
parts = parsed.path.split("/")
|
|
125
|
+
if (
|
|
126
|
+
len(parts) != 5
|
|
127
|
+
or parts[1] != bucket
|
|
128
|
+
or parts[2:4] != ["crawler-quarantine", "v2"]
|
|
129
|
+
or _DIGEST.fullmatch(parts[4]) is None
|
|
130
|
+
):
|
|
131
|
+
_refuse("GCS result URL path is outside its exact quarantine authority")
|
|
132
|
+
try:
|
|
133
|
+
query = parse_qsl(parsed.query, keep_blank_values=True, strict_parsing=True)
|
|
134
|
+
except ValueError:
|
|
135
|
+
_refuse("GCS result URL query is invalid")
|
|
136
|
+
names = {
|
|
137
|
+
"X-Goog-Algorithm",
|
|
138
|
+
"X-Goog-Credential",
|
|
139
|
+
"X-Goog-Date",
|
|
140
|
+
"X-Goog-Expires",
|
|
141
|
+
"X-Goog-SignedHeaders",
|
|
142
|
+
"X-Goog-Signature",
|
|
143
|
+
"generation",
|
|
144
|
+
}
|
|
145
|
+
if len(query) != len(names) or {key for key, _ in query} != names:
|
|
146
|
+
_refuse("GCS result URL query is not exact")
|
|
147
|
+
values = dict(query)
|
|
148
|
+
if (
|
|
149
|
+
any(not item for item in values.values())
|
|
150
|
+
or values["X-Goog-Algorithm"] != "GOOG4-RSA-SHA256"
|
|
151
|
+
or values["X-Goog-SignedHeaders"] != "host"
|
|
152
|
+
or values["generation"] != generation
|
|
153
|
+
or not values["X-Goog-Expires"].isdigit()
|
|
154
|
+
or not 1 <= int(values["X-Goog-Expires"]) <= 900
|
|
155
|
+
or re.fullmatch(r"[0-9]{8}T[0-9]{6}Z", values["X-Goog-Date"]) is None
|
|
156
|
+
or re.fullmatch(r"(?:[0-9A-Fa-f]{2}){64,512}", values["X-Goog-Signature"]) is None
|
|
157
|
+
or re.fullmatch(
|
|
158
|
+
r"[^/\x00-\x20]{1,320}/[0-9]{8}/auto/storage/goog4_request",
|
|
159
|
+
values["X-Goog-Credential"],
|
|
160
|
+
)
|
|
161
|
+
is None
|
|
162
|
+
or values["X-Goog-Credential"].split("/", 2)[1] != values["X-Goog-Date"][:8]
|
|
163
|
+
):
|
|
164
|
+
_refuse("GCS result URL signature policy mismatches")
|
|
165
|
+
return signed_url
|
|
166
|
+
if storage_authority != {"kind": "studio_origin"}:
|
|
167
|
+
_refuse("result storage authority is unsupported")
|
|
168
|
+
if expected_gcs_bucket is not None:
|
|
169
|
+
_refuse("Studio-local result authority cannot replace the trusted GCS deployment pin")
|
|
170
|
+
if generation != "1":
|
|
171
|
+
_refuse("Studio-local result generation is invalid")
|
|
172
|
+
prefix = f"/_crawler-results/{crawler_session_id}/downloads/"
|
|
173
|
+
suffix = parsed.path.removeprefix(prefix)
|
|
174
|
+
try:
|
|
175
|
+
download_id = UUID(suffix)
|
|
176
|
+
except ValueError:
|
|
177
|
+
_refuse("Studio result URL has no canonical download ID")
|
|
178
|
+
if (
|
|
179
|
+
parsed.scheme != base.scheme
|
|
180
|
+
or parsed.hostname != base.hostname
|
|
181
|
+
or port != base.port
|
|
182
|
+
or parsed.query
|
|
183
|
+
or not parsed.path.startswith(prefix)
|
|
184
|
+
or str(download_id) != suffix
|
|
185
|
+
):
|
|
186
|
+
_refuse("Studio result URL differs from the configured origin")
|
|
187
|
+
return signed_url
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def validate_result_download(
|
|
191
|
+
download: Any,
|
|
192
|
+
*,
|
|
193
|
+
base_url: str,
|
|
194
|
+
crawler_session_id: str,
|
|
195
|
+
request_digest: str,
|
|
196
|
+
source_id: str,
|
|
197
|
+
source_authority_digest: str,
|
|
198
|
+
now: datetime,
|
|
199
|
+
expected_gcs_bucket: str | None,
|
|
200
|
+
) -> ValidatedResultDownload:
|
|
201
|
+
fields = {
|
|
202
|
+
"schema_version",
|
|
203
|
+
"crawler_session_id",
|
|
204
|
+
"request_digest",
|
|
205
|
+
"source_id",
|
|
206
|
+
"source_authority_digest",
|
|
207
|
+
"media_type",
|
|
208
|
+
"method",
|
|
209
|
+
"signed_url",
|
|
210
|
+
"required_headers",
|
|
211
|
+
"storage_authority",
|
|
212
|
+
"object_generation",
|
|
213
|
+
"bundle_size_bytes",
|
|
214
|
+
"storage_integrity",
|
|
215
|
+
"max_result_bytes",
|
|
216
|
+
"issued_at",
|
|
217
|
+
"expires_at",
|
|
218
|
+
"redirect_policy",
|
|
219
|
+
"single_use",
|
|
220
|
+
}
|
|
221
|
+
if not isinstance(download, Mapping) or set(download) != fields:
|
|
222
|
+
_refuse("result download fields are not exact")
|
|
223
|
+
issued = _timestamp(download["issued_at"], "result download issued_at")
|
|
224
|
+
expires = _timestamp(download["expires_at"], "result download expires_at")
|
|
225
|
+
size = download["bundle_size_bytes"]
|
|
226
|
+
generation = download["object_generation"]
|
|
227
|
+
integrity = download["storage_integrity"]
|
|
228
|
+
if (
|
|
229
|
+
download["schema_version"] != RESULT_DOWNLOAD_SCHEMA
|
|
230
|
+
or download["crawler_session_id"] != crawler_session_id
|
|
231
|
+
or download["request_digest"] != request_digest
|
|
232
|
+
or download["source_id"] != source_id
|
|
233
|
+
or download["source_authority_digest"] != source_authority_digest
|
|
234
|
+
or download["media_type"] != RESULT_MEDIA_TYPE
|
|
235
|
+
or download["method"] != "GET"
|
|
236
|
+
or download["required_headers"] != []
|
|
237
|
+
or type(size) is not int
|
|
238
|
+
or not 1 <= size <= MAX_CRAWLER_RESULT_BYTES
|
|
239
|
+
or download["max_result_bytes"] != MAX_CRAWLER_RESULT_BYTES
|
|
240
|
+
or not isinstance(generation, str)
|
|
241
|
+
or _GENERATION.fullmatch(generation) is None
|
|
242
|
+
or download["redirect_policy"] != "deny"
|
|
243
|
+
or download["single_use"] is not False
|
|
244
|
+
or issued >= expires
|
|
245
|
+
or issued > now.astimezone(UTC) + timedelta(minutes=1)
|
|
246
|
+
or now.astimezone(UTC) >= expires
|
|
247
|
+
or expires - issued > timedelta(minutes=15)
|
|
248
|
+
or not isinstance(integrity, Mapping)
|
|
249
|
+
or set(integrity) != {"kind", "value"}
|
|
250
|
+
):
|
|
251
|
+
_refuse("result download constants, identity, or expiry are invalid")
|
|
252
|
+
integrity_kind = integrity["kind"]
|
|
253
|
+
integrity_value = integrity["value"]
|
|
254
|
+
if not isinstance(integrity_value, str) or not (
|
|
255
|
+
(integrity_kind == "local_sha256" and _LOCAL_SHA256.fullmatch(integrity_value) is not None)
|
|
256
|
+
or (integrity_kind == "gcs_crc32c" and _GCS_CRC32C.fullmatch(integrity_value) is not None)
|
|
257
|
+
):
|
|
258
|
+
_refuse("result storage integrity is unsupported")
|
|
259
|
+
url = _validate_transfer_url(
|
|
260
|
+
download["signed_url"],
|
|
261
|
+
base_url=base_url,
|
|
262
|
+
crawler_session_id=crawler_session_id,
|
|
263
|
+
generation=generation,
|
|
264
|
+
storage_authority=download["storage_authority"],
|
|
265
|
+
expected_gcs_bucket=expected_gcs_bucket,
|
|
266
|
+
)
|
|
267
|
+
return ValidatedResultDownload(url, size, RESULT_MEDIA_TYPE, dict(integrity))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class ResultBytesVerifier:
|
|
271
|
+
"""Accumulate a result's pinned length and integrity value without holding the result.
|
|
272
|
+
|
|
273
|
+
A hosted result is up to ``MAX_CRAWLER_RESULT_BYTES``, and the caller used to have to hand
|
|
274
|
+
the whole thing over as one ``bytes`` before either check could run. Both checks are
|
|
275
|
+
accumulative, so neither ever needed that: the length is a counter and both pinned integrity
|
|
276
|
+
functions are streaming ones. A transferring caller feeds this verifier bounded chunks and
|
|
277
|
+
keeps none of them; ``verify_result_bytes`` feeds it exactly one chunk and is what it always
|
|
278
|
+
was. One implementation therefore decides both, so a streamed transfer cannot reach a
|
|
279
|
+
verdict a whole-buffer transfer would not.
|
|
280
|
+
|
|
281
|
+
The verdict order is the order the whole-buffer form used and is load-bearing: length first,
|
|
282
|
+
then the pinned value. An unsupported integrity pin is recognised here at construction but
|
|
283
|
+
refused only in :meth:`verify`, so a download that is both the wrong length and pinned by an
|
|
284
|
+
unsupported kind still reports its length, exactly as it did before. Nothing is accepted by
|
|
285
|
+
default -- a pin this module does not implement leaves both accumulators unset and
|
|
286
|
+
:meth:`verify` refuses rather than falling through, and ``_refuse`` is typed ``NoReturn`` so
|
|
287
|
+
that is checkable rather than merely stated here.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
__slots__ = ("_crc32c", "_length", "_pinned", "_sha256", "_size")
|
|
291
|
+
|
|
292
|
+
def __init__(self, validated: ValidatedResultDownload) -> None:
|
|
293
|
+
kind = validated.integrity.get("kind")
|
|
294
|
+
value = validated.integrity.get("value")
|
|
295
|
+
self._size = validated.size
|
|
296
|
+
self._length = 0
|
|
297
|
+
self._pinned: str | None = None
|
|
298
|
+
self._sha256: Any = None
|
|
299
|
+
self._crc32c: int | None = None
|
|
300
|
+
if not isinstance(value, str):
|
|
301
|
+
return
|
|
302
|
+
if kind == "local_sha256" and value.startswith("sha256:"):
|
|
303
|
+
self._pinned = value
|
|
304
|
+
self._sha256 = hashlib.sha256()
|
|
305
|
+
elif kind == "gcs_crc32c":
|
|
306
|
+
self._pinned = value
|
|
307
|
+
self._crc32c = 0xFFFFFFFF
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def length(self) -> int:
|
|
311
|
+
"""Bytes accumulated so far, which is what the length check will be decided on."""
|
|
312
|
+
|
|
313
|
+
return self._length
|
|
314
|
+
|
|
315
|
+
def update(self, chunk: bytes) -> None:
|
|
316
|
+
"""Fold one transferred chunk into the length and the pinned integrity accumulator."""
|
|
317
|
+
|
|
318
|
+
self._length += len(chunk)
|
|
319
|
+
if self._sha256 is not None:
|
|
320
|
+
self._sha256.update(chunk)
|
|
321
|
+
elif self._crc32c is not None:
|
|
322
|
+
checksum = self._crc32c
|
|
323
|
+
table = _CRC32C_TABLE
|
|
324
|
+
for value in chunk:
|
|
325
|
+
checksum = table[(checksum ^ value) & 0xFF] ^ (checksum >> 8)
|
|
326
|
+
self._crc32c = checksum
|
|
327
|
+
|
|
328
|
+
def verify(self) -> None:
|
|
329
|
+
"""Refuse unless the accumulated length and integrity value match the pinned ones."""
|
|
330
|
+
|
|
331
|
+
if self._length != self._size:
|
|
332
|
+
_refuse("result byte length mismatches the generation-pinned size")
|
|
333
|
+
if self._sha256 is not None:
|
|
334
|
+
observed = "sha256:" + self._sha256.hexdigest()
|
|
335
|
+
elif self._crc32c is not None:
|
|
336
|
+
finalized = ((~self._crc32c) & 0xFFFFFFFF).to_bytes(4, "big")
|
|
337
|
+
observed = base64.b64encode(finalized).decode("ascii")
|
|
338
|
+
else:
|
|
339
|
+
_refuse("result storage integrity is unsupported")
|
|
340
|
+
pinned = self._pinned or ""
|
|
341
|
+
if not hmac.compare_digest(pinned.encode("ascii"), observed.encode("ascii")):
|
|
342
|
+
_refuse("result storage integrity mismatches")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def verify_result_bytes(validated: ValidatedResultDownload, content: bytes) -> None:
|
|
346
|
+
"""Verify a result already held whole; the streamed and whole-buffer verdicts are one."""
|
|
347
|
+
|
|
348
|
+
verifier = ResultBytesVerifier(validated)
|
|
349
|
+
verifier.update(content)
|
|
350
|
+
verifier.verify()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
__all__ = [
|
|
354
|
+
"RESULT_CHUNK_BYTES",
|
|
355
|
+
"ResultBytesVerifier",
|
|
356
|
+
"ResultDownloadError",
|
|
357
|
+
"ValidatedResultDownload",
|
|
358
|
+
"validate_result_download",
|
|
359
|
+
"verify_result_bytes",
|
|
360
|
+
]
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Fail-closed retention admission at the content-addressed sealing boundary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mostlyright.data_harness.canonical import parse_json
|
|
11
|
+
|
|
12
|
+
_DELETION_STATUS_VERSION = "harness-source-deletion-status.v1"
|
|
13
|
+
_MAX_EVIDENCE_BYTES = 1 << 20
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _refusal(code: str, path: str, message: str) -> Exception:
|
|
17
|
+
# Import lazily: ``sources`` re-exports adapters that import the HTTP sealer, so importing
|
|
18
|
+
# its contract module while this module is imported would make that public-package cycle.
|
|
19
|
+
from mostlyright.data_harness.sources.contracts import SourceContractError
|
|
20
|
+
|
|
21
|
+
return SourceContractError(code, path, message)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def retention_evidence_root(snapshot_root: Path) -> Path:
|
|
25
|
+
"""The one coordinator evidence directory a snapshot root admits."""
|
|
26
|
+
|
|
27
|
+
return snapshot_root.parent / f".{snapshot_root.name}.deletion-evidence"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def configured_retention_evidence_root(snapshot_root: Path) -> Path | None:
|
|
31
|
+
"""Return a configured authority root; absence means deletion is not governed here."""
|
|
32
|
+
|
|
33
|
+
candidate = retention_evidence_root(snapshot_root)
|
|
34
|
+
try:
|
|
35
|
+
os.lstat(candidate)
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
return None
|
|
38
|
+
except OSError:
|
|
39
|
+
raise _refusal(
|
|
40
|
+
"DELETION_EVIDENCE_ROOT",
|
|
41
|
+
"evidence_root",
|
|
42
|
+
"configured deletion evidence root could not be inspected",
|
|
43
|
+
) from None
|
|
44
|
+
return candidate
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def require_snapshot_admission(
|
|
48
|
+
*,
|
|
49
|
+
evidence_root: Path,
|
|
50
|
+
content_sha256: str,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Refuse to publish or reuse bytes that immutable deletion evidence withdrew.
|
|
53
|
+
|
|
54
|
+
The caller holds the snapshot name's exclusive seal lock. Deletion writes its status
|
|
55
|
+
envelope while holding that same lock before unlinking the snapshot, so a queued
|
|
56
|
+
re-acquisition cannot pass this check between removal and the tombstone write.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
if len(content_sha256) != 64 or any(
|
|
60
|
+
character not in "0123456789abcdef" for character in content_sha256
|
|
61
|
+
):
|
|
62
|
+
raise _refusal(
|
|
63
|
+
"DELETION_ADMISSION",
|
|
64
|
+
"content_sha256",
|
|
65
|
+
"must be a lowercase SHA-256",
|
|
66
|
+
)
|
|
67
|
+
root_fd = _open_evidence_root(evidence_root)
|
|
68
|
+
try:
|
|
69
|
+
status = _read_evidence(root_fd, f"status-{content_sha256}.json")
|
|
70
|
+
tombstone = _read_evidence(root_fd, f"tombstone-{content_sha256}.json")
|
|
71
|
+
finally:
|
|
72
|
+
os.close(root_fd)
|
|
73
|
+
|
|
74
|
+
if status is not None:
|
|
75
|
+
_require_status_binding(status, content_sha256)
|
|
76
|
+
raise _refusal(
|
|
77
|
+
"SOURCE_UNAVAILABLE_BY_DELETION",
|
|
78
|
+
"source",
|
|
79
|
+
"source bytes were deleted; replay, resume, and refetch must not substitute them",
|
|
80
|
+
)
|
|
81
|
+
if tombstone is not None:
|
|
82
|
+
_require_tombstone_binding(tombstone, content_sha256)
|
|
83
|
+
raise _refusal(
|
|
84
|
+
"SOURCE_UNAVAILABLE_BY_DELETION",
|
|
85
|
+
"source",
|
|
86
|
+
"source bytes were deleted; replay, resume, and refetch must not substitute them",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _open_evidence_root(root: Path) -> int:
|
|
91
|
+
if not root.is_absolute():
|
|
92
|
+
raise _refusal(
|
|
93
|
+
"DELETION_EVIDENCE_ROOT",
|
|
94
|
+
"evidence_root",
|
|
95
|
+
"must be an existing absolute non-symlink directory",
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
return os.open(
|
|
99
|
+
root,
|
|
100
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
101
|
+
)
|
|
102
|
+
except OSError:
|
|
103
|
+
raise _refusal(
|
|
104
|
+
"DELETION_EVIDENCE_ROOT",
|
|
105
|
+
"evidence_root",
|
|
106
|
+
"must be an existing absolute non-symlink directory",
|
|
107
|
+
) from None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_evidence(root_fd: int, name: str) -> dict[str, Any] | None:
|
|
111
|
+
try:
|
|
112
|
+
descriptor = os.open(
|
|
113
|
+
name,
|
|
114
|
+
os.O_RDONLY
|
|
115
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
116
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
117
|
+
| getattr(os, "O_CLOEXEC", 0),
|
|
118
|
+
dir_fd=root_fd,
|
|
119
|
+
)
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
return None
|
|
122
|
+
except OSError:
|
|
123
|
+
raise _refusal(
|
|
124
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
125
|
+
name,
|
|
126
|
+
"deletion evidence could not be opened safely",
|
|
127
|
+
) from None
|
|
128
|
+
try:
|
|
129
|
+
info = os.fstat(descriptor)
|
|
130
|
+
if not stat.S_ISREG(info.st_mode) or info.st_size > _MAX_EVIDENCE_BYTES:
|
|
131
|
+
raise _refusal(
|
|
132
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
133
|
+
name,
|
|
134
|
+
"deletion evidence must be a bounded regular file",
|
|
135
|
+
)
|
|
136
|
+
content = _read_exact(descriptor, info.st_size)
|
|
137
|
+
finally:
|
|
138
|
+
os.close(descriptor)
|
|
139
|
+
try:
|
|
140
|
+
value = parse_json(content)
|
|
141
|
+
except Exception:
|
|
142
|
+
raise _refusal(
|
|
143
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
144
|
+
name,
|
|
145
|
+
"deletion evidence is not valid canonical JSON",
|
|
146
|
+
) from None
|
|
147
|
+
if not isinstance(value, dict):
|
|
148
|
+
raise _refusal(
|
|
149
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
150
|
+
name,
|
|
151
|
+
"deletion evidence must be an object",
|
|
152
|
+
)
|
|
153
|
+
return value
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _read_exact(descriptor: int, size: int) -> bytes:
|
|
157
|
+
chunks: list[bytes] = []
|
|
158
|
+
remaining = size
|
|
159
|
+
while remaining:
|
|
160
|
+
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
|
161
|
+
if not chunk:
|
|
162
|
+
break
|
|
163
|
+
chunks.append(chunk)
|
|
164
|
+
remaining -= len(chunk)
|
|
165
|
+
if remaining or os.read(descriptor, 1):
|
|
166
|
+
raise _refusal(
|
|
167
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
168
|
+
"evidence",
|
|
169
|
+
"deletion evidence changed while it was being read",
|
|
170
|
+
)
|
|
171
|
+
return b"".join(chunks)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _require_status_binding(value: dict[str, Any], content_sha256: str) -> None:
|
|
175
|
+
expected = {
|
|
176
|
+
"schema_version",
|
|
177
|
+
"content_sha256",
|
|
178
|
+
"status",
|
|
179
|
+
"source_access_allowed",
|
|
180
|
+
"refetch_allowed",
|
|
181
|
+
"replay",
|
|
182
|
+
"removed_at",
|
|
183
|
+
"reason_code",
|
|
184
|
+
"policy_digest",
|
|
185
|
+
"complete_reference_set_digest",
|
|
186
|
+
"references",
|
|
187
|
+
"planned_tombstone_digest",
|
|
188
|
+
}
|
|
189
|
+
if (
|
|
190
|
+
set(value) != expected
|
|
191
|
+
or value["schema_version"] != _DELETION_STATUS_VERSION
|
|
192
|
+
or value["content_sha256"] != content_sha256
|
|
193
|
+
or value["status"] != "deletion-pending"
|
|
194
|
+
or value["source_access_allowed"] is not False
|
|
195
|
+
or value["refetch_allowed"] is not False
|
|
196
|
+
or value["replay"] != "unavailable-by-deletion"
|
|
197
|
+
or not isinstance(value["references"], list)
|
|
198
|
+
):
|
|
199
|
+
raise _refusal(
|
|
200
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
201
|
+
f"status-{content_sha256}.json",
|
|
202
|
+
"deletion status does not bind an unavailable source",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _require_tombstone_binding(value: dict[str, Any], content_sha256: str) -> None:
|
|
207
|
+
expected = {
|
|
208
|
+
"schema_version",
|
|
209
|
+
"tombstone_id",
|
|
210
|
+
"removed_content_sha256",
|
|
211
|
+
"removed_at",
|
|
212
|
+
"reason_code",
|
|
213
|
+
"policy_digest",
|
|
214
|
+
"complete_reference_set_digest",
|
|
215
|
+
"references",
|
|
216
|
+
"byte_removal_confirmed",
|
|
217
|
+
}
|
|
218
|
+
if set(value) != expected or value["removed_content_sha256"] != content_sha256:
|
|
219
|
+
raise _refusal(
|
|
220
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
221
|
+
f"tombstone-{content_sha256}.json",
|
|
222
|
+
"tombstone does not bind confirmed removal of this source",
|
|
223
|
+
)
|
|
224
|
+
try:
|
|
225
|
+
from mostlyright.data_harness.sources.contracts import (
|
|
226
|
+
DeletedReference,
|
|
227
|
+
DeletionTombstone,
|
|
228
|
+
SourceContractError,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
references = tuple(DeletedReference(**item) for item in value["references"])
|
|
232
|
+
DeletionTombstone(
|
|
233
|
+
schema_version=value["schema_version"],
|
|
234
|
+
tombstone_id=value["tombstone_id"],
|
|
235
|
+
removed_content_sha256=value["removed_content_sha256"],
|
|
236
|
+
removed_at=value["removed_at"],
|
|
237
|
+
reason_code=value["reason_code"],
|
|
238
|
+
policy_digest=value["policy_digest"],
|
|
239
|
+
complete_reference_set_digest=value["complete_reference_set_digest"],
|
|
240
|
+
references=references,
|
|
241
|
+
byte_removal_confirmed=value["byte_removal_confirmed"],
|
|
242
|
+
)
|
|
243
|
+
except (TypeError, SourceContractError):
|
|
244
|
+
raise _refusal(
|
|
245
|
+
"DELETION_EVIDENCE_CONFLICT",
|
|
246
|
+
f"tombstone-{content_sha256}.json",
|
|
247
|
+
"tombstone has an invalid closed deletion contract",
|
|
248
|
+
) from None
|