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,1137 @@
|
|
|
1
|
+
"""``mr-data acquire``, answered by Studio's clean room and by the Receipt Studio bound.
|
|
2
|
+
|
|
3
|
+
WHAT WAS MISSING, AND WHAT CLOSED IT. The hosted clean room has existed since ADR 0015 and a full
|
|
4
|
+
install already uses it: with neither attestation, the local ``acquire`` submits to Studio, waits,
|
|
5
|
+
downloads the quarantined bundle, and then binds the Receipt **on this computer** against the
|
|
6
|
+
Toolbox -- which resolves the exact Reader the request pinned and the budgets that Reader's
|
|
7
|
+
certified family narrows to. A thin profile carries no Toolbox, so that last step had no hosted
|
|
8
|
+
answer, and PR #312 recorded the gap with the sentence that named what would close it: *Studio
|
|
9
|
+
returning the bound Receipt itself*. Studio PR #95 does exactly that, at
|
|
10
|
+
``GET /v3/workspaces/{ws}/public-acquisitions/{id}/receipt``.
|
|
11
|
+
|
|
12
|
+
⚠ THE BINDING IS NOT RE-IMPLEMENTED HERE, AND THAT IS THE POINT. Nothing below re-derives a
|
|
13
|
+
Reader's certified defaults, re-checks a decode-options digest, or re-narrows a budget. Copying
|
|
14
|
+
that closed, Certification-gated table into this repository would be a second copy of a
|
|
15
|
+
security-critical fact with nothing holding the two in step -- precisely the shape the boundary
|
|
16
|
+
carve-out forbids, and precisely the reason this command was pending rather than written.
|
|
17
|
+
|
|
18
|
+
WHAT THIS LANE DOES CHECK is the half that is about the bytes rather than about the Reader: the
|
|
19
|
+
Receipt is the Receipt for the session that was opened, the bundle hashes to the digest Studio's
|
|
20
|
+
binding declares, and the normalized content hashes to the digest the Receipt declares. Those are
|
|
21
|
+
digests over documents already in hand, not a second opinion about a Toolbox.
|
|
22
|
+
|
|
23
|
+
⚠ AND IT SAYS WHICH BINDING IT GOT. ``receipt_binding.reader_binding`` is ``none`` when the request
|
|
24
|
+
pinned no Reader -- in which case the Receipt carries no Toolbox-derived fact at all and Studio's
|
|
25
|
+
binding IS the whole binding -- and ``worker_toolbox`` when one was pinned, where the Toolbox that
|
|
26
|
+
resolved it was the one inside the clean room. Both land in the receipt under their own key with
|
|
27
|
+
the sentence that says what the difference is, the same posture ``verify`` set with ``checked_by``:
|
|
28
|
+
state which check was performed rather than letting one word carry two meanings.
|
|
29
|
+
|
|
30
|
+
A COURIER THAT REACHED THE SOURCE AND REFUSED **completed** the session. It answers here as
|
|
31
|
+
``outcome: "refused"`` carrying its sealed refusal, and this renders it as a refusal -- the local
|
|
32
|
+
lane's own ``status: "refused"`` payload, its headline, its exit code -- rather than as an error
|
|
33
|
+
about one. A publisher saying no is an answer.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import argparse
|
|
39
|
+
import base64
|
|
40
|
+
import hashlib
|
|
41
|
+
import re
|
|
42
|
+
import time
|
|
43
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any
|
|
46
|
+
from urllib.parse import urlsplit
|
|
47
|
+
from uuid import UUID
|
|
48
|
+
|
|
49
|
+
from mostlyright.data_harness.canonical import (
|
|
50
|
+
CanonicalJSONError,
|
|
51
|
+
canonical_sha256,
|
|
52
|
+
parse_foreign_json,
|
|
53
|
+
)
|
|
54
|
+
from mostlyright.data_harness.thin.download import download_signed_artifact
|
|
55
|
+
from mostlyright.data_harness.thin.parity import _no_effect
|
|
56
|
+
from mostlyright.data_harness.thin.runs import StudioApiClient
|
|
57
|
+
from mostlyright.data_harness.thin.session import StudioSession, open_studio_session
|
|
58
|
+
from mostlyright.data_harness.thin.transport import ThinLaneError
|
|
59
|
+
from mostlyright.data_harness.ux.credentials import resolve_cloud_credentials
|
|
60
|
+
from mostlyright.data_harness.ux.path_kind import UNKNOWN_KIND, name_of_kind, presence_at
|
|
61
|
+
from mostlyright.data_harness.ux.plain_file import (
|
|
62
|
+
DANGLING,
|
|
63
|
+
MISSING,
|
|
64
|
+
NOT_PLAIN,
|
|
65
|
+
PlainFileRefusal,
|
|
66
|
+
read_plain_file,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------------------------------------------
|
|
70
|
+
# Words and numbers this lane must not invent
|
|
71
|
+
# --------------------------------------------------------------------------------------------
|
|
72
|
+
#
|
|
73
|
+
# Every constant below is a literal the local lane already owns, restated because the module that
|
|
74
|
+
# holds it reaches the engine and cannot be imported on a thin install. A restated literal is a
|
|
75
|
+
# literal that can drift, so `tests/test_thin_parity.py` holds each one equal to its original on a
|
|
76
|
+
# full install, where both sides import.
|
|
77
|
+
|
|
78
|
+
#: ``cli.ACQUISITION_DOCUMENT_VERSION`` and ``cli.ACQUISITION_RECEIPT_VERSION``.
|
|
79
|
+
ACQUISITION_DOCUMENT_VERSION = "mr-data-acquisition.v1"
|
|
80
|
+
ACQUISITION_RECEIPT_VERSION = "mr-data-acquire-receipt.v1"
|
|
81
|
+
|
|
82
|
+
#: ``sources.collections.COLLECTION_ADAPTER_ID``.
|
|
83
|
+
COLLECTION_ADAPTER_ID = "public.https.collection"
|
|
84
|
+
|
|
85
|
+
#: ``cli._HOSTED_LIMIT_CAPS``: the hosted Courier contract's own ceilings
|
|
86
|
+
#: (``public-crawl.schema.json#/$defs/public_https_query/properties/limits``). Refused here, by
|
|
87
|
+
#: name, before this command contacts anything -- Studio would refuse the same document schema-side
|
|
88
|
+
#: as an unexplained 422 that names none of them.
|
|
89
|
+
HOSTED_LIMIT_CAPS: dict[str, int] = {
|
|
90
|
+
"max_source_bytes": 268_435_456,
|
|
91
|
+
"max_normalized_bytes": 4_294_967_296,
|
|
92
|
+
"max_rows": 10_000_000,
|
|
93
|
+
"max_columns": 1_024,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#: ``cli._MAX_DOCUMENT_BYTES``, which is ``ParseLimits().max_input_bytes``.
|
|
97
|
+
MAX_DOCUMENT_BYTES = 16 * 1024 * 1024
|
|
98
|
+
|
|
99
|
+
#: ``cli._DENY_DEFAULT_POSTURE``.
|
|
100
|
+
DENY_DEFAULT_POSTURE = (
|
|
101
|
+
"Nothing is fetchable by default; every acquisition supplies its own exact host allowlist."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
#: The two headlines ``cli._acquisition_facts`` and ``cli._acquire_refusal`` open with, for the
|
|
105
|
+
#: single-address case -- the only case a hosted acquisition has.
|
|
106
|
+
ACQUIRED_HEADLINE = "The Courier fetched one address in a clean room and sealed its Receipt."
|
|
107
|
+
REFUSED_HEADLINE = "This acquisition was refused."
|
|
108
|
+
|
|
109
|
+
#: The two sentences ``ux.hosted_acquisition`` prints about a Courier that reached the source and
|
|
110
|
+
#: said no, and about one that did not get that far.
|
|
111
|
+
COURIER_REFUSAL_DETAIL = (
|
|
112
|
+
"the hosted public acquisition was safely refused inside the isolated Courier"
|
|
113
|
+
)
|
|
114
|
+
COURIER_FAILURE_DETAIL = "the hosted public acquisition failed inside the isolated Courier"
|
|
115
|
+
|
|
116
|
+
#: ``cli._acquire``'s two hosted-route labels, and the authority behind the attestation it reports.
|
|
117
|
+
HOSTED_ACQUISITION_ENVIRONMENT = "studio_hosted_clean_room"
|
|
118
|
+
HOSTED_ATTESTATION_AUTHORITY = "studio_trusted_coordinator"
|
|
119
|
+
|
|
120
|
+
#: The one refusal the hosted route owes a bounded collection, with ``cli``'s own code and words.
|
|
121
|
+
#: PR #312 put this sentence on ``acquire-slices``, which is a different command about a different
|
|
122
|
+
#: problem; Studio's design note (``docs/platform/HOSTED-SLICE-ACQUISITION.md``) found the mix-up
|
|
123
|
+
#: and it is corrected in both places at once.
|
|
124
|
+
COLLECTION_UNSUPPORTED_CODE = "ACQUIRE_HOSTED_COLLECTION_UNSUPPORTED"
|
|
125
|
+
COLLECTION_UNSUPPORTED_DETAIL = (
|
|
126
|
+
"bounded collections currently require the local attested acquisition route"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
#: ``sources.hosted``'s transport envelope, restated as the one shape whose digest the local
|
|
130
|
+
#: hosted route reports as ``clean_room_attestation_digest``. It is a digest over five facts
|
|
131
|
+
#: Studio already stated, not a check: computing it here reproduces the local lane's value exactly,
|
|
132
|
+
#: which is what makes that key mean the same thing in both profiles.
|
|
133
|
+
TRANSPORT_ENVELOPE_SCHEMA = "hosted-public-https-transport-envelope.v1"
|
|
134
|
+
CLEAN_ROOM = {
|
|
135
|
+
"host_platform": "linux-amd64",
|
|
136
|
+
"parser_network_syscalls": "denied",
|
|
137
|
+
"parser_process_creation": "denied",
|
|
138
|
+
"no_new_privs": True,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#: ``ux.hosted_acquisition.source_authority_digest``'s document.
|
|
142
|
+
SOURCE_AUTHORITY_SCHEMA = "mr-data-hosted-public-source-authority.v1"
|
|
143
|
+
|
|
144
|
+
#: ``ux.hosted_acquisition._RECEIPT_FIELDS``: the exact key set a Receipt must carry before the
|
|
145
|
+
#: local lane will believe one. Studio's own suite restates this same literal and asserts the
|
|
146
|
+
#: published Receipt equals it; this is the harness half of the same agreement, and a field added
|
|
147
|
+
#: on either side and not the other fails on both.
|
|
148
|
+
RECEIPT_FIELDS = frozenset(
|
|
149
|
+
{
|
|
150
|
+
"schema_version",
|
|
151
|
+
"source_id",
|
|
152
|
+
"adapter_id",
|
|
153
|
+
"adapter_version",
|
|
154
|
+
"request_digest",
|
|
155
|
+
"source_url",
|
|
156
|
+
"final_url",
|
|
157
|
+
"fetched_media_type",
|
|
158
|
+
"normalized_media_type",
|
|
159
|
+
"normalized_data_format",
|
|
160
|
+
"normalized_filename",
|
|
161
|
+
"acquired_at",
|
|
162
|
+
"raw_content_sha256",
|
|
163
|
+
"raw_size_bytes",
|
|
164
|
+
"normalized_content_sha256",
|
|
165
|
+
"normalized_size_bytes",
|
|
166
|
+
"row_count",
|
|
167
|
+
"column_names",
|
|
168
|
+
"parsed_schema_digest",
|
|
169
|
+
"family_id",
|
|
170
|
+
"family_version",
|
|
171
|
+
"decode_options_digest",
|
|
172
|
+
"decode_flags",
|
|
173
|
+
"resource_caps",
|
|
174
|
+
"reader_budgets",
|
|
175
|
+
"transport_evidence_digest",
|
|
176
|
+
"egress_policy_attestation",
|
|
177
|
+
"clean_room",
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# --------------------------------------------------------------------------------------------
|
|
182
|
+
# This lane's own vocabulary
|
|
183
|
+
# --------------------------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
#: The routes.
|
|
186
|
+
CREATE_PUBLIC_ACQUISITION_PATH = "/v3/workspaces/{workspace_id}/public-acquisitions"
|
|
187
|
+
GET_PUBLIC_ACQUISITION_PATH = (
|
|
188
|
+
"/v3/workspaces/{workspace_id}/public-acquisitions/{crawler_session_id}"
|
|
189
|
+
)
|
|
190
|
+
PUBLIC_ACQUISITION_RECEIPT_PATH = (
|
|
191
|
+
"/v3/workspaces/{workspace_id}/public-acquisitions/{crawler_session_id}/receipt"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
#: ``public-crawl.schema.json#/$defs/public_acquisition_session/properties/status``.
|
|
195
|
+
SESSION_STATUSES = ("enqueue_pending", "enqueued", "result_ready", "failed", "cancelled")
|
|
196
|
+
|
|
197
|
+
#: What each value of ``receipt_binding.reader_binding`` means, in the words a person needs. Not a
|
|
198
|
+
#: paraphrase of the schema's description: the schema explains the mechanism, and this says what
|
|
199
|
+
#: the reader of a receipt should conclude.
|
|
200
|
+
READER_BINDING_SENTENCE = {
|
|
201
|
+
"none": (
|
|
202
|
+
"this request pinned no Reader, so the Receipt carries no fact a Toolbox would have had "
|
|
203
|
+
"to resolve, and Studio's binding is the whole binding -- it answers exactly what a "
|
|
204
|
+
"Toolbox on this computer would have answered"
|
|
205
|
+
),
|
|
206
|
+
"worker_toolbox": (
|
|
207
|
+
"this request pinned a Reader, and the Toolbox that resolved it was the one inside the "
|
|
208
|
+
"clean room that decoded the bytes; Studio bound the Receipt's budgets to this request's "
|
|
209
|
+
"own limits and caps without re-deriving that family's certified defaults"
|
|
210
|
+
),
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
#: The one key the local receipt carries that a hosted acquisition genuinely does not have, and
|
|
214
|
+
#: why it is absent rather than empty. ``tests/test_thin_parity.py`` holds the two key sets equal
|
|
215
|
+
#: apart from exactly this, so a field that quietly stopped being reported fails there.
|
|
216
|
+
LOCAL_ONLY_RECEIPT_FIELDS = {
|
|
217
|
+
"observation_digest": (
|
|
218
|
+
"an observation is sealed by the local adapter pipeline, which a hosted profile does not "
|
|
219
|
+
"run; what Studio holds about this acquisition is its session and the Receipt below"
|
|
220
|
+
)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#: How long to wait for the clean room, and how the wait grows. The same ceiling
|
|
224
|
+
#: ``ux.hosted_acquisition.POLL_TIMEOUT_SECONDS`` gives the local hosted route, so the two profiles
|
|
225
|
+
#: give up on the same acquisition at the same moment.
|
|
226
|
+
POLL_TIMEOUT_SECONDS = 1800.0
|
|
227
|
+
POLL_INITIAL_SECONDS = 0.5
|
|
228
|
+
POLL_MAX_SECONDS = 5.0
|
|
229
|
+
|
|
230
|
+
#: A hard ceiling on the quarantined bundle this lane will bring back, above the derived bound
|
|
231
|
+
#: below and below anything that would be a memory problem on a laptop. The contract admits a 6 GiB
|
|
232
|
+
#: bundle; a thin client parses the document whole, so it says what it will not do rather than
|
|
233
|
+
#: discovering it as a MemoryError.
|
|
234
|
+
MAX_RESULT_BUNDLE_BYTES = 512 * 1024 * 1024
|
|
235
|
+
|
|
236
|
+
#: The slack the derived bound allows over the base64url expansion of the normalized content: the
|
|
237
|
+
#: Receipt, the observation and the envelope around them.
|
|
238
|
+
RESULT_BUNDLE_OVERHEAD_BYTES = 1024 * 1024
|
|
239
|
+
|
|
240
|
+
_DIGEST = re.compile(r"^[0-9a-f]{64}$")
|
|
241
|
+
_FAILURE_CODE = re.compile(r"^[A-Z][A-Z0-9_]{2,127}$")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _failure_code(value: Any, fallback: str) -> str:
|
|
245
|
+
"""Return a bounded machine code from Studio, never its arbitrary diagnostic text.
|
|
246
|
+
|
|
247
|
+
``failure_code`` is the existing terminal worker-result channel. A crawler bootstrap failure
|
|
248
|
+
has no upload capability yet, so it cannot produce the usual signed failure bundle; Studio
|
|
249
|
+
settles the acquisition with this field instead. Preserve its exact stable code, but do not
|
|
250
|
+
let a malformed server response turn an error body, token, or line break into CLI output.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
return value if isinstance(value, str) and _FAILURE_CODE.fullmatch(value) else fallback
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class AcquireRefusal(ThinLaneError):
|
|
257
|
+
"""A refusal this command renders as its own receipt rather than as an error.
|
|
258
|
+
|
|
259
|
+
Distinct from a plain :class:`ThinLaneError` because the two are different answers. A
|
|
260
|
+
``ThinLaneError`` is "this call did not happen"; this is "it happened and the answer was no",
|
|
261
|
+
which the local lane already prints as a full ``status: "refused"`` document with an operator
|
|
262
|
+
action beside it. Routing the second through the first would report a publisher's refusal in
|
|
263
|
+
the words used for a broken client.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
def __init__(self, code: str, detail: str, action: str, **extra: Any) -> None:
|
|
267
|
+
super().__init__(code, detail)
|
|
268
|
+
self.action = action
|
|
269
|
+
self.extra = extra
|
|
270
|
+
|
|
271
|
+
def to_receipt(self) -> dict[str, Any]:
|
|
272
|
+
return {
|
|
273
|
+
"headline": REFUSED_HEADLINE,
|
|
274
|
+
"schema_version": ACQUISITION_RECEIPT_VERSION,
|
|
275
|
+
"status": "refused",
|
|
276
|
+
"lane": "hosted",
|
|
277
|
+
"code": self.code,
|
|
278
|
+
"refusal": self.detail,
|
|
279
|
+
"operator_action": self.action,
|
|
280
|
+
**self.extra,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class StudioAcquisitionClient(StudioApiClient):
|
|
285
|
+
"""Open one hosted acquisition, watch it, and read the Receipt Studio bound for it."""
|
|
286
|
+
|
|
287
|
+
def create(self, body: Mapping[str, Any], *, idempotency_key: str) -> dict[str, Any]:
|
|
288
|
+
return self._call(
|
|
289
|
+
"POST",
|
|
290
|
+
CREATE_PUBLIC_ACQUISITION_PATH.format(
|
|
291
|
+
workspace_id=self._session.workspace_id,
|
|
292
|
+
),
|
|
293
|
+
body=body,
|
|
294
|
+
extra_headers={"Idempotency-Key": idempotency_key},
|
|
295
|
+
expected=(202,),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def get(self, crawler_session_id: str) -> dict[str, Any]:
|
|
299
|
+
return self._call(
|
|
300
|
+
"GET",
|
|
301
|
+
GET_PUBLIC_ACQUISITION_PATH.format(
|
|
302
|
+
workspace_id=self._session.workspace_id,
|
|
303
|
+
crawler_session_id=crawler_session_id,
|
|
304
|
+
),
|
|
305
|
+
expected=(200,),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def receipt(self, crawler_session_id: str) -> dict[str, Any]:
|
|
309
|
+
return self._call(
|
|
310
|
+
"GET",
|
|
311
|
+
PUBLIC_ACQUISITION_RECEIPT_PATH.format(
|
|
312
|
+
workspace_id=self._session.workspace_id,
|
|
313
|
+
crawler_session_id=crawler_session_id,
|
|
314
|
+
),
|
|
315
|
+
expected=(200,),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# --------------------------------------------------------------------------------------------
|
|
320
|
+
# The operator's document, read without the engine
|
|
321
|
+
# --------------------------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def read_acquisition_document(path: Path) -> dict[str, Any]:
|
|
325
|
+
"""Read the acquisition document under the same rule every named path is read under.
|
|
326
|
+
|
|
327
|
+
``ux.plain_file`` rather than ``read_bytes``: open once, ask the descriptor what it is, read
|
|
328
|
+
that descriptor under a bound. A hosted profile does not get a laxer rule about a path somebody
|
|
329
|
+
typed than the local profile has.
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
raw = read_plain_file(path, max_bytes=MAX_DOCUMENT_BYTES)
|
|
334
|
+
except PlainFileRefusal as refusal:
|
|
335
|
+
raise AcquireRefusal(
|
|
336
|
+
"ACQUISITION_DOCUMENT_UNREADABLE",
|
|
337
|
+
_why(refusal),
|
|
338
|
+
"point --acquisition at a readable canonical JSON acquisition document",
|
|
339
|
+
) from None
|
|
340
|
+
except OSError as error:
|
|
341
|
+
raise AcquireRefusal(
|
|
342
|
+
"ACQUISITION_DOCUMENT_UNREADABLE",
|
|
343
|
+
f"the acquisition document could not be read: {error}",
|
|
344
|
+
"point --acquisition at a readable canonical JSON acquisition document",
|
|
345
|
+
) from error
|
|
346
|
+
try:
|
|
347
|
+
value = parse_foreign_json(raw)
|
|
348
|
+
except CanonicalJSONError as error:
|
|
349
|
+
raise AcquireRefusal(
|
|
350
|
+
"ACQUISITION_DOCUMENT_UNREADABLE",
|
|
351
|
+
f"the acquisition document could not be read as canonical JSON: {error}",
|
|
352
|
+
"point --acquisition at a readable canonical JSON acquisition document",
|
|
353
|
+
) from error
|
|
354
|
+
if not isinstance(value, dict):
|
|
355
|
+
raise AcquireRefusal(
|
|
356
|
+
"ACQUISITION_DOCUMENT_UNREADABLE",
|
|
357
|
+
"the acquisition document is not a JSON object",
|
|
358
|
+
"point --acquisition at a readable canonical JSON acquisition document",
|
|
359
|
+
)
|
|
360
|
+
return value
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _why(refusal: PlainFileRefusal) -> str:
|
|
364
|
+
"""Why one named path did not yield a document, in the words the local lane already uses.
|
|
365
|
+
|
|
366
|
+
⚠ THE KIND IS NAMED FROM THE DESCRIPTOR, AND THE SENTENCE CARRIES THE PATH IT LOOKED AT.
|
|
367
|
+
``ux.path_kind.name_of_kind`` reads the mode the open actually observed, and the same sentence
|
|
368
|
+
names ``refusal.path``, so what is claimed and what was inspected are visibly the same
|
|
369
|
+
coordinate -- which is exactly what ``scripts/path_kind_gate.py`` reads these strings for. A
|
|
370
|
+
sentence that named a kind without the path it looked at would be a claim about some other
|
|
371
|
+
file.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
if refusal.reason == MISSING:
|
|
375
|
+
# ⚠ `presence_at`, not the word "nothing". `MISSING` says the open found no entry, and the
|
|
376
|
+
# sentence still has to look before it says so: the one thing `peek` learned the hard way
|
|
377
|
+
# is that "there is nothing there" said about a link sitting right in front of somebody
|
|
378
|
+
# sends them to check their spelling instead of to repoint it.
|
|
379
|
+
return (
|
|
380
|
+
f"there is {presence_at(refusal.path) or UNKNOWN_KIND} at {refusal.path}: this "
|
|
381
|
+
"command reads a document out of a file"
|
|
382
|
+
)
|
|
383
|
+
if refusal.reason == DANGLING:
|
|
384
|
+
return (
|
|
385
|
+
f"there is {name_of_kind(refusal.mode) or UNKNOWN_KIND} at {refusal.path}, and it "
|
|
386
|
+
"leads nowhere: this command reads a document out of a file"
|
|
387
|
+
)
|
|
388
|
+
if refusal.reason == NOT_PLAIN:
|
|
389
|
+
return (
|
|
390
|
+
f"there is {name_of_kind(refusal.mode) or UNKNOWN_KIND} at {refusal.path}: this "
|
|
391
|
+
"command reads a document out of a file, not out of a pipe, a socket, or a device"
|
|
392
|
+
)
|
|
393
|
+
return f"{refusal.path} holds more than {MAX_DOCUMENT_BYTES} bytes"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def hosted_query(document: Mapping[str, Any]) -> dict[str, Any]:
|
|
397
|
+
"""Compose the exact ``public_https_query`` this acquisition asks Studio for.
|
|
398
|
+
|
|
399
|
+
Composed here from the operator's own document, field for field, in the shape
|
|
400
|
+
``sources.hosted.HostedPublicHttpsAdapter`` composes it on a full install -- and no wider. A
|
|
401
|
+
hosted profile has no adapter registry to run governance through, so what it can do is refuse
|
|
402
|
+
every document the hosted route would refuse anyway, name the refusal, and send exactly the
|
|
403
|
+
query the contract admits.
|
|
404
|
+
"""
|
|
405
|
+
|
|
406
|
+
if document.get("schema_version") != ACQUISITION_DOCUMENT_VERSION:
|
|
407
|
+
raise AcquireRefusal(
|
|
408
|
+
"ACQUISITION_DOCUMENT_VERSION",
|
|
409
|
+
f"the acquisition document must state schema_version {ACQUISITION_DOCUMENT_VERSION!r}",
|
|
410
|
+
"correct the document's schema_version and run again",
|
|
411
|
+
)
|
|
412
|
+
adapter = _object(document, "adapter")
|
|
413
|
+
request = _object(document, "request")
|
|
414
|
+
limits = _limits(document)
|
|
415
|
+
adapter_id = adapter.get("adapter_id")
|
|
416
|
+
if adapter_id == COLLECTION_ADAPTER_ID:
|
|
417
|
+
raise AcquireRefusal(
|
|
418
|
+
COLLECTION_UNSUPPORTED_CODE,
|
|
419
|
+
COLLECTION_UNSUPPORTED_DETAIL,
|
|
420
|
+
"acquire this collection's members one address at a time, or run it on a full "
|
|
421
|
+
"install with both --clean-room-attestation and --network-policy-attestation",
|
|
422
|
+
)
|
|
423
|
+
if adapter.get("source_class") not in {"user_url", "user_api", "external_adapter"}:
|
|
424
|
+
raise AcquireRefusal(
|
|
425
|
+
"ACQUIRE_HOSTED_ADAPTER_CLASS",
|
|
426
|
+
"the hosted public HTTPS clean room fetches a user_url, user_api or external_adapter "
|
|
427
|
+
"source, and this document names another class",
|
|
428
|
+
"correct adapter.source_class, or run this on a full install with both attestations",
|
|
429
|
+
)
|
|
430
|
+
if request.get("credential_reference_id") is not None:
|
|
431
|
+
raise AcquireRefusal(
|
|
432
|
+
"AUTHENTICATED_SOURCE_REQUIRES_TRUSTED_GATEWAY",
|
|
433
|
+
"hosted public acquisition receives no source credential",
|
|
434
|
+
"route an authenticated source through a registered typed acquisition boundary",
|
|
435
|
+
)
|
|
436
|
+
query = _object(request, "query")
|
|
437
|
+
url = query.get("url")
|
|
438
|
+
if not isinstance(url, str) or not url.startswith("https://") or urlsplit(url).port is not None:
|
|
439
|
+
raise AcquireRefusal(
|
|
440
|
+
"ACQUIRE_TARGET_UNREADABLE",
|
|
441
|
+
"the request query states no credential-free https address on the implicit port 443",
|
|
442
|
+
"state query.url as an https address without a port, user information or a fragment",
|
|
443
|
+
)
|
|
444
|
+
data_format = query.get("data_format")
|
|
445
|
+
filename = query.get("filename")
|
|
446
|
+
if not isinstance(data_format, str) or not isinstance(filename, str) or not filename:
|
|
447
|
+
raise AcquireRefusal(
|
|
448
|
+
"ACQUIRE_OUTPUT_COORDINATES",
|
|
449
|
+
"the request query states no data_format and filename for what comes back",
|
|
450
|
+
"state query.data_format and query.filename in the acquisition document",
|
|
451
|
+
)
|
|
452
|
+
reader_pin = request.get("reader_pin")
|
|
453
|
+
resource_caps = request.get("resource_caps")
|
|
454
|
+
if reader_pin is None and resource_caps is not None:
|
|
455
|
+
raise AcquireRefusal(
|
|
456
|
+
"ACQUIRE_RESOURCE_CAPS_WITHOUT_READER",
|
|
457
|
+
"resource caps narrow a pinned Reader's budgets, and this request pins no Reader",
|
|
458
|
+
"pin the Reader these caps narrow, or drop request.resource_caps",
|
|
459
|
+
)
|
|
460
|
+
return {
|
|
461
|
+
"url": url,
|
|
462
|
+
"data_format": data_format,
|
|
463
|
+
"filename": filename,
|
|
464
|
+
"reader_pin": None if reader_pin is None else dict(_mapping(reader_pin, "reader_pin")),
|
|
465
|
+
"resource_caps": (
|
|
466
|
+
None if resource_caps is None else dict(_mapping(resource_caps, "resource_caps"))
|
|
467
|
+
),
|
|
468
|
+
"limits": limits,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _object(value: Mapping[str, Any], field: str) -> Mapping[str, Any]:
|
|
473
|
+
selected = value.get(field)
|
|
474
|
+
if not isinstance(selected, Mapping):
|
|
475
|
+
raise AcquireRefusal(
|
|
476
|
+
"ACQUISITION_DOCUMENT_UNREADABLE",
|
|
477
|
+
f"the acquisition document states no {field} object",
|
|
478
|
+
"correct the acquisition document and run again",
|
|
479
|
+
)
|
|
480
|
+
return selected
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _mapping(value: Any, field: str) -> Mapping[str, Any]:
|
|
484
|
+
if not isinstance(value, Mapping):
|
|
485
|
+
raise AcquireRefusal(
|
|
486
|
+
"ACQUISITION_DOCUMENT_UNREADABLE",
|
|
487
|
+
f"request.{field} is stated but is not an object",
|
|
488
|
+
"correct the acquisition document and run again",
|
|
489
|
+
)
|
|
490
|
+
return value
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _limits(document: Mapping[str, Any]) -> dict[str, int]:
|
|
494
|
+
"""The four bounds, refused against the hosted contract's caps before anything is contacted."""
|
|
495
|
+
|
|
496
|
+
stated = _object(document, "limits")
|
|
497
|
+
limits: dict[str, int] = {}
|
|
498
|
+
for name in HOSTED_LIMIT_CAPS:
|
|
499
|
+
value = stated.get(name)
|
|
500
|
+
if name == "max_normalized_bytes" and value is None:
|
|
501
|
+
value = stated.get("max_source_bytes")
|
|
502
|
+
if type(value) is not int or value < 1:
|
|
503
|
+
raise AcquireRefusal(
|
|
504
|
+
"ACQUISITION_DOCUMENT_UNREADABLE",
|
|
505
|
+
f"the acquisition document states no whole-number limits.{name}",
|
|
506
|
+
"state every limit this acquisition runs under, as a whole number",
|
|
507
|
+
)
|
|
508
|
+
limits[name] = value
|
|
509
|
+
over = {name: limits[name] for name, cap in HOSTED_LIMIT_CAPS.items() if limits[name] > cap}
|
|
510
|
+
if over:
|
|
511
|
+
raise AcquireRefusal(
|
|
512
|
+
"ACQUIRE_HOSTED_LIMITS_OVER_CAP",
|
|
513
|
+
"this document's limits exceed the hosted Courier contract caps: "
|
|
514
|
+
+ "; ".join(
|
|
515
|
+
f"{name}={value} exceeds the hosted cap {HOSTED_LIMIT_CAPS[name]}"
|
|
516
|
+
for name, value in sorted(over.items())
|
|
517
|
+
),
|
|
518
|
+
"lower the named limits to the hosted caps below and run again, or run this on a full "
|
|
519
|
+
"install with both --clean-room-attestation and --network-policy-attestation",
|
|
520
|
+
hosted_limit_caps=dict(HOSTED_LIMIT_CAPS),
|
|
521
|
+
)
|
|
522
|
+
return limits
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def allowed_hostnames(values: Sequence[str] | None) -> tuple[str, ...]:
|
|
526
|
+
"""The exact hosts this acquisition may reach. There is no default and no override."""
|
|
527
|
+
|
|
528
|
+
if not values:
|
|
529
|
+
raise AcquireRefusal(
|
|
530
|
+
"ACQUIRE_EGRESS_ALLOWLIST_REQUIRED",
|
|
531
|
+
DENY_DEFAULT_POSTURE + " No --allow-host was supplied.",
|
|
532
|
+
"pass --allow-host <hostname> once for each exact host this acquisition may reach",
|
|
533
|
+
)
|
|
534
|
+
return tuple(sorted(dict.fromkeys(values)))
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def require_allowlisted_target(url: str, hostnames: tuple[str, ...]) -> None:
|
|
538
|
+
"""Refuse an address outside the supplied allowlist before anything is contacted.
|
|
539
|
+
|
|
540
|
+
⚠ NOT THE AUTHORITY, in either profile. Studio's reviewed egress policy is what actually
|
|
541
|
+
bounds the Courier, and it is applied inside the clean room against the composed address. This
|
|
542
|
+
is the same early refusal the local lane makes for the same reason: an address the operator
|
|
543
|
+
never allowlisted should be a named sentence at the terminal, before a token is minted and a
|
|
544
|
+
session exists on the backend.
|
|
545
|
+
"""
|
|
546
|
+
|
|
547
|
+
parsed = urlsplit(url)
|
|
548
|
+
hostname = (parsed.hostname or "").lower()
|
|
549
|
+
if not hostname or hostname not in hostnames:
|
|
550
|
+
raise AcquireRefusal(
|
|
551
|
+
"ACQUIRE_EGRESS_DENIED",
|
|
552
|
+
f"the requested host {hostname!r} is not in the allowlist supplied for this "
|
|
553
|
+
"acquisition",
|
|
554
|
+
"pass --allow-host for that exact host, or correct the address in the document",
|
|
555
|
+
host_allowlist=list(hostnames),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def source_authority_digest(source_id: str, query: Mapping[str, Any]) -> str:
|
|
560
|
+
"""The digest that binds this exact query to this exact source, as the local lane computes it.
|
|
561
|
+
|
|
562
|
+
Restated rather than imported for the reason every literal at the top of this module is, and
|
|
563
|
+
held byte-equal to ``ux.hosted_acquisition.source_authority_digest`` by a drift gate: the two
|
|
564
|
+
profiles must ask Studio for the same acquisition, and a digest that differed by a field would
|
|
565
|
+
open a second session for the same request and neither would be wrong about it.
|
|
566
|
+
"""
|
|
567
|
+
|
|
568
|
+
return canonical_sha256(
|
|
569
|
+
{
|
|
570
|
+
"schema_version": SOURCE_AUTHORITY_SCHEMA,
|
|
571
|
+
"source_id": source_id,
|
|
572
|
+
"adapter_id": "public.https",
|
|
573
|
+
"adapter_version": "1.0.0",
|
|
574
|
+
"query": dict(query),
|
|
575
|
+
}
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def transport_envelope_digest(receipt: Mapping[str, Any]) -> str:
|
|
580
|
+
"""``clean_room_attestation_digest``, reproduced from facts Studio already stated.
|
|
581
|
+
|
|
582
|
+
The same five-field envelope ``sources.hosted`` seals on a full install, over the same values.
|
|
583
|
+
It is a digest and not a check -- nothing here is verified by computing it -- and it is
|
|
584
|
+
computed rather than omitted precisely so that key means the same thing in both profiles.
|
|
585
|
+
"""
|
|
586
|
+
|
|
587
|
+
return canonical_sha256(
|
|
588
|
+
{
|
|
589
|
+
"schema_version": TRANSPORT_ENVELOPE_SCHEMA,
|
|
590
|
+
"hosted_request_digest": receipt.get("request_digest"),
|
|
591
|
+
"crawler_transport_evidence_digest": receipt.get("transport_evidence_digest"),
|
|
592
|
+
"egress_policy_attestation": receipt.get("egress_policy_attestation"),
|
|
593
|
+
"clean_room": dict(CLEAN_ROOM),
|
|
594
|
+
}
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# --------------------------------------------------------------------------------------------
|
|
599
|
+
# The command
|
|
600
|
+
# --------------------------------------------------------------------------------------------
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _session(args: argparse.Namespace) -> StudioSession:
|
|
604
|
+
return open_studio_session(resolve_cloud_credentials())
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _client(args: argparse.Namespace) -> StudioAcquisitionClient:
|
|
608
|
+
return StudioAcquisitionClient(_session(args))
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
#: The local flags a hosted acquisition cannot honour, and the sentence each one gets.
|
|
612
|
+
_ACQUIRE_NO_EFFECT = (
|
|
613
|
+
(
|
|
614
|
+
"clean_room_attestation",
|
|
615
|
+
"--clean-room-attestation",
|
|
616
|
+
"the clean room this ran in is Studio's, and its policy is attested by Studio rather than "
|
|
617
|
+
"by whoever typed this command",
|
|
618
|
+
),
|
|
619
|
+
(
|
|
620
|
+
"network_policy_attestation",
|
|
621
|
+
"--network-policy-attestation",
|
|
622
|
+
"the egress policy this ran under is the reviewed hosted one, and the Receipt carries its "
|
|
623
|
+
"attestation",
|
|
624
|
+
),
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def acquire(
|
|
629
|
+
args: argparse.Namespace,
|
|
630
|
+
*,
|
|
631
|
+
client: StudioAcquisitionClient | None = None,
|
|
632
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
633
|
+
clock: Callable[[], float] = time.monotonic,
|
|
634
|
+
) -> dict[str, Any]:
|
|
635
|
+
"""``mr-data acquire``: fetch one public address in Studio's clean room, bound Receipt and all.
|
|
636
|
+
|
|
637
|
+
Same document, same flags, same receipt keys as the local lane's hosted route -- which is the
|
|
638
|
+
route a full install already takes when it is given neither attestation. What differs is where
|
|
639
|
+
the last step happens, and the payload names that rather than leaving the word Receipt to carry
|
|
640
|
+
it.
|
|
641
|
+
"""
|
|
642
|
+
|
|
643
|
+
document = read_acquisition_document(Path(args.acquisition).expanduser())
|
|
644
|
+
query = hosted_query(document)
|
|
645
|
+
hostnames = allowed_hostnames(getattr(args, "allow_host", None))
|
|
646
|
+
require_allowlisted_target(query["url"], hostnames)
|
|
647
|
+
_refuse_cadence(args)
|
|
648
|
+
snapshots, staging = _acquire_directories(Path(args.output).expanduser())
|
|
649
|
+
request = _object(document, "request")
|
|
650
|
+
source_id = request.get("source_id")
|
|
651
|
+
if not isinstance(source_id, str) or not source_id:
|
|
652
|
+
raise AcquireRefusal(
|
|
653
|
+
"ACQUISITION_DOCUMENT_UNREADABLE",
|
|
654
|
+
"the acquisition document names no source_id",
|
|
655
|
+
"state request.source_id in the acquisition document",
|
|
656
|
+
)
|
|
657
|
+
authority = source_authority_digest(source_id, query)
|
|
658
|
+
selected = client or _client(args)
|
|
659
|
+
session = _open_session(
|
|
660
|
+
selected,
|
|
661
|
+
source_id=source_id,
|
|
662
|
+
authority=authority,
|
|
663
|
+
query=query,
|
|
664
|
+
attempt_id=str(getattr(args, "attempt_id", "")),
|
|
665
|
+
)
|
|
666
|
+
crawler_session_id = _session_id(session)
|
|
667
|
+
session = _await_result(selected, crawler_session_id, session, sleep=sleep, clock=clock)
|
|
668
|
+
published = selected.receipt(crawler_session_id)
|
|
669
|
+
_bind_published_receipt(
|
|
670
|
+
published,
|
|
671
|
+
crawler_session_id=crawler_session_id,
|
|
672
|
+
workspace_id=selected.session.workspace_id,
|
|
673
|
+
source_id=source_id,
|
|
674
|
+
authority=authority,
|
|
675
|
+
request_digest=session.get("request_digest"),
|
|
676
|
+
)
|
|
677
|
+
if published.get("outcome") == "refused":
|
|
678
|
+
raise _courier_refusal(published)
|
|
679
|
+
receipt = published.get("acquisition_receipt")
|
|
680
|
+
binding = published.get("receipt_binding")
|
|
681
|
+
if not isinstance(receipt, Mapping) or not isinstance(binding, Mapping):
|
|
682
|
+
raise ThinLaneError(
|
|
683
|
+
"THIN_RESPONSE_INVALID", "Studio reported an acquisition with no bound Receipt"
|
|
684
|
+
)
|
|
685
|
+
if set(receipt) != RECEIPT_FIELDS:
|
|
686
|
+
raise ThinLaneError(
|
|
687
|
+
"THIN_RECEIPT_FIELDS",
|
|
688
|
+
"Studio published a Receipt whose fields are not the fields this contract declares; "
|
|
689
|
+
"the two repositories are pinned to different versions of it",
|
|
690
|
+
)
|
|
691
|
+
content = _fetch_normalized_content(selected, session, receipt, binding, staging)
|
|
692
|
+
snapshot = _seal(snapshots, content, receipt)
|
|
693
|
+
return _acquisition_facts(
|
|
694
|
+
document=document,
|
|
695
|
+
receipt=receipt,
|
|
696
|
+
binding=binding,
|
|
697
|
+
snapshot=snapshot,
|
|
698
|
+
hostnames=hostnames,
|
|
699
|
+
crawler_session_id=crawler_session_id,
|
|
700
|
+
workspace_id=str(selected.session.workspace_id),
|
|
701
|
+
args=args,
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def acquire_exit_code(payload: Mapping[str, Any]) -> int:
|
|
706
|
+
"""``1`` when the answer was no, matching what the local command already exits with."""
|
|
707
|
+
|
|
708
|
+
return 1 if payload.get("status") == "refused" else 0
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _refuse_cadence(args: argparse.Namespace) -> None:
|
|
712
|
+
"""The local refusal, unchanged: a probe run in Studio's clean room is recorded by Studio.
|
|
713
|
+
|
|
714
|
+
Kept identical rather than made to work, because the fact behind it has not changed. An
|
|
715
|
+
empirical cadence history is a record of probes THIS computer made, and a hosted probe is one
|
|
716
|
+
Studio made; appending it here would put a local timestamp on somebody else's observation.
|
|
717
|
+
"""
|
|
718
|
+
|
|
719
|
+
if getattr(args, "cadence_history", None) is None:
|
|
720
|
+
if getattr(args, "cadence_authority", None) is not None:
|
|
721
|
+
raise AcquireRefusal(
|
|
722
|
+
"CADENCE_HISTORY_REQUIRED",
|
|
723
|
+
"a cadence authority was supplied with no history to record it in",
|
|
724
|
+
"pass --cadence-history with the folder this source's probes are recorded in",
|
|
725
|
+
)
|
|
726
|
+
return
|
|
727
|
+
raise AcquireRefusal(
|
|
728
|
+
"CADENCE_HOSTED_ROUTE_UNSUPPORTED",
|
|
729
|
+
"a probe run in Studio's hosted clean room is recorded by Studio, not here",
|
|
730
|
+
"run this probe on a full install with both attestations, or drop --cadence-history",
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def _acquire_directories(output: Path) -> tuple[Path, Path]:
|
|
735
|
+
"""The snapshot and staging roots, both inside the operator's output directory."""
|
|
736
|
+
|
|
737
|
+
directory = output.absolute()
|
|
738
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
739
|
+
raise AcquireRefusal(
|
|
740
|
+
"ACQUIRE_OUTPUT_DIRECTORY",
|
|
741
|
+
"--output must name an existing directory that is not a symlink",
|
|
742
|
+
"create the output directory first; everything this command writes stays inside it",
|
|
743
|
+
)
|
|
744
|
+
roots: list[Path] = []
|
|
745
|
+
for name in ("snapshots", "staging"):
|
|
746
|
+
child = directory / name
|
|
747
|
+
child.mkdir(mode=0o700, exist_ok=True)
|
|
748
|
+
if child.is_symlink() or not child.is_dir():
|
|
749
|
+
raise AcquireRefusal(
|
|
750
|
+
"ACQUIRE_OUTPUT_DIRECTORY",
|
|
751
|
+
f"the output directory's {name!r} entry is not a directory",
|
|
752
|
+
"remove that entry, or point --output at a clean directory",
|
|
753
|
+
)
|
|
754
|
+
roots.append(child)
|
|
755
|
+
return roots[0], roots[1]
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _open_session(
|
|
759
|
+
client: StudioAcquisitionClient,
|
|
760
|
+
*,
|
|
761
|
+
source_id: str,
|
|
762
|
+
authority: str,
|
|
763
|
+
query: Mapping[str, Any],
|
|
764
|
+
attempt_id: str,
|
|
765
|
+
) -> dict[str, Any]:
|
|
766
|
+
"""Ask Studio for one clean-room acquisition of exactly this query.
|
|
767
|
+
|
|
768
|
+
The idempotency key is derived from the workspace, the authority digest and the attempt, not
|
|
769
|
+
minted at random: a retry of one attempt must reach the same session rather than open a second
|
|
770
|
+
clean room against a stranger's server, and two different attempts must not collide.
|
|
771
|
+
"""
|
|
772
|
+
|
|
773
|
+
key = "mr-data-acquire-" + canonical_sha256(
|
|
774
|
+
{
|
|
775
|
+
"schema_version": "hosted-public-acquisition-idempotency.v1",
|
|
776
|
+
"workspace_id": str(client.session.workspace_id),
|
|
777
|
+
"source_authority_digest": authority,
|
|
778
|
+
"acquisition_attempt_id": attempt_id,
|
|
779
|
+
}
|
|
780
|
+
)
|
|
781
|
+
return client.create(
|
|
782
|
+
{
|
|
783
|
+
"schema_version": "3.0.0",
|
|
784
|
+
"workspace_id": str(client.session.workspace_id),
|
|
785
|
+
"source_id": source_id,
|
|
786
|
+
"source_authority_digest": authority,
|
|
787
|
+
"query": dict(query),
|
|
788
|
+
},
|
|
789
|
+
idempotency_key=key,
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _session_id(session: Mapping[str, Any]) -> str:
|
|
794
|
+
value = session.get("crawler_session_id")
|
|
795
|
+
try:
|
|
796
|
+
return str(UUID(str(value)))
|
|
797
|
+
except (ValueError, AttributeError) as error:
|
|
798
|
+
raise ThinLaneError(
|
|
799
|
+
"THIN_RESPONSE_INVALID", "Studio opened an acquisition without an identifier"
|
|
800
|
+
) from error
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _await_result(
|
|
804
|
+
client: StudioAcquisitionClient,
|
|
805
|
+
crawler_session_id: str,
|
|
806
|
+
session: Mapping[str, Any],
|
|
807
|
+
*,
|
|
808
|
+
sleep: Callable[[float], None],
|
|
809
|
+
clock: Callable[[], float],
|
|
810
|
+
) -> dict[str, Any]:
|
|
811
|
+
"""Wait for the clean room, refusing a session Studio moved under us.
|
|
812
|
+
|
|
813
|
+
The immutable coordinates are compared on every poll. A session whose request digest or
|
|
814
|
+
requester changed between two readings is not a slow session, it is a different one, and
|
|
815
|
+
following it would be downloading bytes fetched for somebody else's request.
|
|
816
|
+
"""
|
|
817
|
+
|
|
818
|
+
observed = dict(session)
|
|
819
|
+
deadline = clock() + POLL_TIMEOUT_SECONDS
|
|
820
|
+
delay = POLL_INITIAL_SECONDS
|
|
821
|
+
while observed.get("status") not in {"result_ready", "failed", "cancelled"}:
|
|
822
|
+
if clock() >= deadline:
|
|
823
|
+
raise AcquireRefusal(
|
|
824
|
+
"HOSTED_ACQUISITION_TIMEOUT",
|
|
825
|
+
"the hosted public acquisition did not finish",
|
|
826
|
+
"run this again; the session is Studio's and the attempt identifier reaches it",
|
|
827
|
+
)
|
|
828
|
+
sleep(delay)
|
|
829
|
+
delay = min(delay * 2, POLL_MAX_SECONDS)
|
|
830
|
+
polled = client.get(crawler_session_id)
|
|
831
|
+
for field in (
|
|
832
|
+
"schema_version",
|
|
833
|
+
"crawler_session_id",
|
|
834
|
+
"workspace_id",
|
|
835
|
+
"requester_principal_id",
|
|
836
|
+
"source_id",
|
|
837
|
+
"source_authority_digest",
|
|
838
|
+
"adapter_id",
|
|
839
|
+
"adapter_version",
|
|
840
|
+
"request_id",
|
|
841
|
+
"request_digest",
|
|
842
|
+
"egress_policy_attestation",
|
|
843
|
+
"created_at",
|
|
844
|
+
"expires_at",
|
|
845
|
+
):
|
|
846
|
+
if polled.get(field) != observed.get(field):
|
|
847
|
+
raise ThinLaneError(
|
|
848
|
+
"THIN_SESSION_BINDING",
|
|
849
|
+
"Studio changed an immutable hosted acquisition coordinate",
|
|
850
|
+
)
|
|
851
|
+
observed = polled
|
|
852
|
+
if observed.get("status") == "failed":
|
|
853
|
+
raise AcquireRefusal(
|
|
854
|
+
_failure_code(observed.get("failure_code"), "HOSTED_ACQUISITION_FAILED"),
|
|
855
|
+
COURIER_FAILURE_DETAIL,
|
|
856
|
+
"read the failure code above; it names what the isolated Courier stopped on",
|
|
857
|
+
)
|
|
858
|
+
if observed.get("status") == "cancelled":
|
|
859
|
+
raise AcquireRefusal(
|
|
860
|
+
"HOSTED_ACQUISITION_CANCELLED",
|
|
861
|
+
"the hosted public acquisition was cancelled",
|
|
862
|
+
"the clean-room work was stopped; start a new acquisition only if the source is "
|
|
863
|
+
"still needed",
|
|
864
|
+
)
|
|
865
|
+
return observed
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _bind_published_receipt(
|
|
869
|
+
published: Mapping[str, Any],
|
|
870
|
+
*,
|
|
871
|
+
crawler_session_id: str,
|
|
872
|
+
workspace_id: UUID,
|
|
873
|
+
source_id: str,
|
|
874
|
+
authority: str,
|
|
875
|
+
request_digest: Any,
|
|
876
|
+
) -> None:
|
|
877
|
+
"""Refuse a Receipt that is not this session's, before anything is read out of it.
|
|
878
|
+
|
|
879
|
+
Five equalities, and none of them is about a Reader. This is the whole of what a client can
|
|
880
|
+
settle for itself when the binding was performed elsewhere: that the document in hand is the
|
|
881
|
+
document for the acquisition it asked for.
|
|
882
|
+
"""
|
|
883
|
+
|
|
884
|
+
if (
|
|
885
|
+
published.get("schema_version") != "mostlyright-public-acquisition-receipt.v1"
|
|
886
|
+
or published.get("crawler_session_id") != crawler_session_id
|
|
887
|
+
or published.get("workspace_id") != str(workspace_id)
|
|
888
|
+
or published.get("source_id") != source_id
|
|
889
|
+
or published.get("source_authority_digest") != authority
|
|
890
|
+
or published.get("request_digest") != request_digest
|
|
891
|
+
or published.get("outcome") not in {"acquired", "refused"}
|
|
892
|
+
):
|
|
893
|
+
raise ThinLaneError(
|
|
894
|
+
"THIN_RECEIPT_BINDING",
|
|
895
|
+
"Studio published a Receipt that is not this acquisition session's",
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _courier_refusal(published: Mapping[str, Any]) -> AcquireRefusal:
|
|
900
|
+
"""The Courier reached the source and the source said no. That is an answer."""
|
|
901
|
+
|
|
902
|
+
failure = published.get("crawler_failure")
|
|
903
|
+
failure = failure if isinstance(failure, Mapping) else {}
|
|
904
|
+
return AcquireRefusal(
|
|
905
|
+
_failure_code(failure.get("failure_code"), "HOSTED_ACQUISITION_REFUSED"),
|
|
906
|
+
COURIER_REFUSAL_DETAIL,
|
|
907
|
+
"the code above is the source's own refusal, sealed by the Courier; nothing about this "
|
|
908
|
+
"machine changes it",
|
|
909
|
+
retryable=failure.get("retryable"),
|
|
910
|
+
crawler_session_id=published.get("crawler_session_id"),
|
|
911
|
+
source_id=published.get("source_id"),
|
|
912
|
+
request_digest=published.get("request_digest"),
|
|
913
|
+
network_fetch_performed=True,
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _fetch_normalized_content(
|
|
918
|
+
client: StudioAcquisitionClient,
|
|
919
|
+
session: Mapping[str, Any],
|
|
920
|
+
receipt: Mapping[str, Any],
|
|
921
|
+
binding: Mapping[str, Any],
|
|
922
|
+
staging: Path,
|
|
923
|
+
) -> bytes:
|
|
924
|
+
"""Bring the quarantined bundle back, and hash it twice before believing a byte of it.
|
|
925
|
+
|
|
926
|
+
Once against ``receipt_binding.result_digest``, which is what Studio authenticated, and once
|
|
927
|
+
against the Receipt's own ``normalized_content_sha256`` after the chunks are decoded. Both,
|
|
928
|
+
rather than either: the first says the bundle is the bundle, the second says the bytes about to
|
|
929
|
+
be written are the bytes the Receipt describes.
|
|
930
|
+
"""
|
|
931
|
+
|
|
932
|
+
download = session.get("result_download")
|
|
933
|
+
if not isinstance(download, Mapping):
|
|
934
|
+
raise ThinLaneError(
|
|
935
|
+
"THIN_RESPONSE_INVALID", "Studio reported a ready acquisition with no download"
|
|
936
|
+
)
|
|
937
|
+
declared = download.get("bundle_size_bytes")
|
|
938
|
+
normalized_size = receipt.get("normalized_size_bytes")
|
|
939
|
+
if type(declared) is not int or type(normalized_size) is not int:
|
|
940
|
+
raise ThinLaneError(
|
|
941
|
+
"THIN_RESPONSE_INVALID", "the quarantined bundle declares no usable size"
|
|
942
|
+
)
|
|
943
|
+
bound = min(
|
|
944
|
+
MAX_RESULT_BUNDLE_BYTES,
|
|
945
|
+
-(-normalized_size // 3) * 4 + RESULT_BUNDLE_OVERHEAD_BYTES,
|
|
946
|
+
)
|
|
947
|
+
if declared > bound:
|
|
948
|
+
raise AcquireRefusal(
|
|
949
|
+
"ACQUIRE_RESULT_BUNDLE_TOO_LARGE",
|
|
950
|
+
f"the quarantined result bundle declares {declared} bytes, above the {bound} this "
|
|
951
|
+
"profile reads whole",
|
|
952
|
+
"acquire this source on a full install, which streams the bundle rather than reading "
|
|
953
|
+
"it whole",
|
|
954
|
+
)
|
|
955
|
+
result_digest = binding.get("result_digest")
|
|
956
|
+
if not isinstance(result_digest, str) or _DIGEST.fullmatch(result_digest) is None:
|
|
957
|
+
raise ThinLaneError(
|
|
958
|
+
"THIN_RESPONSE_INVALID", "Studio's binding carries no result digest to check against"
|
|
959
|
+
)
|
|
960
|
+
destination = staging / f"{session['crawler_session_id']}.hosted-crawler-result.json"
|
|
961
|
+
artifact = download_signed_artifact(
|
|
962
|
+
{
|
|
963
|
+
"signed_url": download.get("signed_url", ""),
|
|
964
|
+
"expected_content_digest": f"sha256:{result_digest}",
|
|
965
|
+
"expected_size_bytes": declared,
|
|
966
|
+
"media_type": download.get("media_type"),
|
|
967
|
+
"required_headers": download.get("required_headers"),
|
|
968
|
+
},
|
|
969
|
+
destination,
|
|
970
|
+
transport=client.transport,
|
|
971
|
+
)
|
|
972
|
+
try:
|
|
973
|
+
raw = artifact.path.read_bytes()
|
|
974
|
+
content = _normalized_content(raw, receipt)
|
|
975
|
+
finally:
|
|
976
|
+
# The quarantined bundle is not an output of this command; the snapshot is. Leaving it
|
|
977
|
+
# behind would put an unlabelled copy of somebody's source bytes in a folder nobody was
|
|
978
|
+
# told about.
|
|
979
|
+
artifact.path.unlink(missing_ok=True)
|
|
980
|
+
return content
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _normalized_content(raw: bytes, receipt: Mapping[str, Any]) -> bytes:
|
|
984
|
+
"""Decode the bundle's chunks and refuse them unless they are the bytes the Receipt names."""
|
|
985
|
+
|
|
986
|
+
try:
|
|
987
|
+
bundle = parse_foreign_json(raw)
|
|
988
|
+
except CanonicalJSONError as error:
|
|
989
|
+
raise ThinLaneError(
|
|
990
|
+
"THIN_RESULT_INVALID", "the quarantined result bundle is not a readable document"
|
|
991
|
+
) from error
|
|
992
|
+
chunks = bundle.get("normalized_content_base64url_chunks") if isinstance(bundle, dict) else None
|
|
993
|
+
if not isinstance(chunks, list) or not chunks:
|
|
994
|
+
raise ThinLaneError(
|
|
995
|
+
"THIN_RESULT_INVALID", "the quarantined result bundle carries no normalized content"
|
|
996
|
+
)
|
|
997
|
+
joined = "".join(chunk for chunk in chunks if isinstance(chunk, str))
|
|
998
|
+
try:
|
|
999
|
+
content = base64.urlsafe_b64decode(joined + "=" * (-len(joined) % 4))
|
|
1000
|
+
except (ValueError, TypeError) as error:
|
|
1001
|
+
raise ThinLaneError(
|
|
1002
|
+
"THIN_RESULT_INVALID", "the quarantined result bundle's content is not base64url"
|
|
1003
|
+
) from error
|
|
1004
|
+
observed = hashlib.sha256(content).hexdigest()
|
|
1005
|
+
if observed != receipt.get("normalized_content_sha256") or len(content) != receipt.get(
|
|
1006
|
+
"normalized_size_bytes"
|
|
1007
|
+
):
|
|
1008
|
+
raise ThinLaneError(
|
|
1009
|
+
"THIN_RESULT_DIGEST_MISMATCH",
|
|
1010
|
+
"the bytes in the quarantined bundle are not the bytes the bound Receipt describes",
|
|
1011
|
+
)
|
|
1012
|
+
return content
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def _seal(snapshots: Path, content: bytes, receipt: Mapping[str, Any]) -> Path:
|
|
1016
|
+
"""Write the normalized bytes under their own digest, never over something already there.
|
|
1017
|
+
|
|
1018
|
+
The same content-addressed name ``acquisition.http.seal_content_addressed_snapshot`` writes on
|
|
1019
|
+
a full install, so a snapshot folder filled by either profile reads the same way. An existing
|
|
1020
|
+
file with this name already holds these exact bytes -- the name IS the digest -- so it is left
|
|
1021
|
+
alone rather than rewritten.
|
|
1022
|
+
"""
|
|
1023
|
+
|
|
1024
|
+
suffix = receipt.get("normalized_data_format")
|
|
1025
|
+
if not isinstance(suffix, str) or not suffix.isalnum():
|
|
1026
|
+
raise ThinLaneError(
|
|
1027
|
+
"THIN_RESPONSE_INVALID", "the bound Receipt names no output format for these bytes"
|
|
1028
|
+
)
|
|
1029
|
+
destination = snapshots / f"{hashlib.sha256(content).hexdigest()}.{suffix}"
|
|
1030
|
+
if destination.exists():
|
|
1031
|
+
return destination
|
|
1032
|
+
staged = destination.with_name(destination.name + ".partial")
|
|
1033
|
+
staged.write_bytes(content)
|
|
1034
|
+
staged.replace(destination)
|
|
1035
|
+
return destination
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _acquisition_facts(
|
|
1039
|
+
*,
|
|
1040
|
+
document: Mapping[str, Any],
|
|
1041
|
+
receipt: Mapping[str, Any],
|
|
1042
|
+
binding: Mapping[str, Any],
|
|
1043
|
+
snapshot: Path,
|
|
1044
|
+
hostnames: tuple[str, ...],
|
|
1045
|
+
crawler_session_id: str,
|
|
1046
|
+
workspace_id: str,
|
|
1047
|
+
args: argparse.Namespace,
|
|
1048
|
+
) -> dict[str, Any]:
|
|
1049
|
+
"""The receipt, under the keys the local lane already uses for the same facts.
|
|
1050
|
+
|
|
1051
|
+
``tests/test_thin_parity.py`` holds this key set equal to ``cli._acquisition_facts``'s, apart
|
|
1052
|
+
from :data:`LOCAL_ONLY_RECEIPT_FIELDS`, so a script that reads ``content_sha256`` reads it in
|
|
1053
|
+
both profiles and a key that quietly stopped being reported fails there rather than in
|
|
1054
|
+
somebody's pipeline.
|
|
1055
|
+
"""
|
|
1056
|
+
|
|
1057
|
+
reader_binding = binding.get("reader_binding")
|
|
1058
|
+
adapter = _object(document, "adapter")
|
|
1059
|
+
return {
|
|
1060
|
+
"headline": ACQUIRED_HEADLINE,
|
|
1061
|
+
"schema_version": ACQUISITION_RECEIPT_VERSION,
|
|
1062
|
+
"status": "source_acquired",
|
|
1063
|
+
"lane": "hosted",
|
|
1064
|
+
"source_id": receipt.get("source_id"),
|
|
1065
|
+
"adapter_id": receipt.get("adapter_id"),
|
|
1066
|
+
"adapter_version": receipt.get("adapter_version"),
|
|
1067
|
+
"source_class": adapter.get("source_class"),
|
|
1068
|
+
"media_type": receipt.get("normalized_media_type"),
|
|
1069
|
+
"data_format": receipt.get("normalized_data_format"),
|
|
1070
|
+
"size_bytes": receipt.get("normalized_size_bytes"),
|
|
1071
|
+
"content_sha256": receipt.get("normalized_content_sha256"),
|
|
1072
|
+
"snapshot_path": str(snapshot),
|
|
1073
|
+
"receipt_digest": canonical_sha256(dict(receipt)),
|
|
1074
|
+
"host_allowlist": list(hostnames),
|
|
1075
|
+
"acquisition_environment": HOSTED_ACQUISITION_ENVIRONMENT,
|
|
1076
|
+
"clean_room_attestation_digest": transport_envelope_digest(receipt),
|
|
1077
|
+
"external_network_policy_attestation": receipt.get("egress_policy_attestation"),
|
|
1078
|
+
"attestation_authority": HOSTED_ATTESTATION_AUTHORITY,
|
|
1079
|
+
"reader_decode_identity_present": receipt.get("family_id") is not None,
|
|
1080
|
+
"network_fetch_performed": True,
|
|
1081
|
+
"source_uri": receipt.get("source_url"),
|
|
1082
|
+
"final_uri": receipt.get("final_url"),
|
|
1083
|
+
# The honest half. Everything above is a fact about the acquisition; these three are facts
|
|
1084
|
+
# about who established them, which is the one thing that differs between the profiles.
|
|
1085
|
+
"bound_by": binding.get("bound_by"),
|
|
1086
|
+
"reader_binding": reader_binding,
|
|
1087
|
+
"binding_note": READER_BINDING_SENTENCE.get(
|
|
1088
|
+
str(reader_binding),
|
|
1089
|
+
"Studio named a binding this client has no sentence for; read receipt_binding below",
|
|
1090
|
+
),
|
|
1091
|
+
"crawler_session_id": crawler_session_id,
|
|
1092
|
+
"workspace_id": workspace_id,
|
|
1093
|
+
"receipt": dict(receipt),
|
|
1094
|
+
"receipt_binding": dict(binding),
|
|
1095
|
+
"not_reported_here": dict(LOCAL_ONLY_RECEIPT_FIELDS),
|
|
1096
|
+
"flags_without_effect": _no_effect(args, _ACQUIRE_NO_EFFECT),
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
__all__ = [
|
|
1101
|
+
"ACQUIRED_HEADLINE",
|
|
1102
|
+
"ACQUISITION_DOCUMENT_VERSION",
|
|
1103
|
+
"ACQUISITION_RECEIPT_VERSION",
|
|
1104
|
+
"CLEAN_ROOM",
|
|
1105
|
+
"COLLECTION_ADAPTER_ID",
|
|
1106
|
+
"COLLECTION_UNSUPPORTED_CODE",
|
|
1107
|
+
"COLLECTION_UNSUPPORTED_DETAIL",
|
|
1108
|
+
"COURIER_FAILURE_DETAIL",
|
|
1109
|
+
"COURIER_REFUSAL_DETAIL",
|
|
1110
|
+
"CREATE_PUBLIC_ACQUISITION_PATH",
|
|
1111
|
+
"DENY_DEFAULT_POSTURE",
|
|
1112
|
+
"GET_PUBLIC_ACQUISITION_PATH",
|
|
1113
|
+
"HOSTED_ACQUISITION_ENVIRONMENT",
|
|
1114
|
+
"HOSTED_ATTESTATION_AUTHORITY",
|
|
1115
|
+
"HOSTED_LIMIT_CAPS",
|
|
1116
|
+
"LOCAL_ONLY_RECEIPT_FIELDS",
|
|
1117
|
+
"MAX_DOCUMENT_BYTES",
|
|
1118
|
+
"MAX_RESULT_BUNDLE_BYTES",
|
|
1119
|
+
"POLL_TIMEOUT_SECONDS",
|
|
1120
|
+
"PUBLIC_ACQUISITION_RECEIPT_PATH",
|
|
1121
|
+
"READER_BINDING_SENTENCE",
|
|
1122
|
+
"RECEIPT_FIELDS",
|
|
1123
|
+
"REFUSED_HEADLINE",
|
|
1124
|
+
"SESSION_STATUSES",
|
|
1125
|
+
"SOURCE_AUTHORITY_SCHEMA",
|
|
1126
|
+
"TRANSPORT_ENVELOPE_SCHEMA",
|
|
1127
|
+
"AcquireRefusal",
|
|
1128
|
+
"StudioAcquisitionClient",
|
|
1129
|
+
"acquire",
|
|
1130
|
+
"acquire_exit_code",
|
|
1131
|
+
"allowed_hostnames",
|
|
1132
|
+
"hosted_query",
|
|
1133
|
+
"read_acquisition_document",
|
|
1134
|
+
"require_allowlisted_target",
|
|
1135
|
+
"source_authority_digest",
|
|
1136
|
+
"transport_envelope_digest",
|
|
1137
|
+
]
|