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,477 @@
|
|
|
1
|
+
"""Admission rules that keep the shared catalog public by construction.
|
|
2
|
+
|
|
3
|
+
The catalog is shared across tenants. Harvested bytes therefore pass the repository's bounded
|
|
4
|
+
public-content classifier, and every typed evidence locator is parsed and checked against the
|
|
5
|
+
coordinator's public-host policy before an entry can be sealed.
|
|
6
|
+
|
|
7
|
+
Prose has a stricter, structural contract: it carries no locators. If any conservative form of a
|
|
8
|
+
prose string contains a URL-scheme shape, the entry is refused and the locator must move to a typed
|
|
9
|
+
citation field. Over-detection costs one refused entry; a parser/scanner disagreement cannot admit
|
|
10
|
+
tenant-private text because no prose locator is admitted at all.
|
|
11
|
+
|
|
12
|
+
The walk remains derived from ``CatalogEntry.to_dict()`` -- the exact shape written to
|
|
13
|
+
``catalog.json`` -- so every serialized prose field is covered without enumerating field names.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import ipaddress
|
|
19
|
+
import re
|
|
20
|
+
import unicodedata
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from typing import Any
|
|
23
|
+
from urllib.parse import SplitResult, unquote, urlsplit
|
|
24
|
+
|
|
25
|
+
from mostlyright.data_harness.acquisition.url_policy import (
|
|
26
|
+
AcquisitionSecurityError,
|
|
27
|
+
EgressPolicy,
|
|
28
|
+
canonical_public_hostname,
|
|
29
|
+
)
|
|
30
|
+
from mostlyright.data_harness.sources.catalog.contracts import CatalogEntry
|
|
31
|
+
from mostlyright.data_harness.sources.contracts import SourceContractError
|
|
32
|
+
from mostlyright.data_harness.sources.governance import (
|
|
33
|
+
ClassificationResult,
|
|
34
|
+
classify_and_require_admissible,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Four MiB. Deliberately four times tighter than the classifier's own 16 MiB scan cap: harvested
|
|
38
|
+
# metadata documents are small, and the tight cap is what stops an SDMX-scale structure response
|
|
39
|
+
# (the observed Eurostat one is 37,166,239 bytes) from ever reaching a scan.
|
|
40
|
+
MAX_CATALOG_FACT_BYTES = 4 * 1024 * 1024
|
|
41
|
+
|
|
42
|
+
CATALOG_CLASSIFICATION = "public"
|
|
43
|
+
|
|
44
|
+
# Names that are never a public source, regardless of what DNS would say about them.
|
|
45
|
+
_FORBIDDEN_HOST_SUFFIXES = (".local", ".internal", ".localdomain")
|
|
46
|
+
_FORBIDDEN_HOST_NAMES = frozenset({"localhost"})
|
|
47
|
+
|
|
48
|
+
_CITATION_PORT = 443
|
|
49
|
+
|
|
50
|
+
# Special schemes can introduce a URL directly at their colon; other schemes can introduce an
|
|
51
|
+
# authority when followed by a slash or backslash. These deliberately broad patterns answer only
|
|
52
|
+
# whether prose can carry a locator. They never try to find its end or decide which host it names.
|
|
53
|
+
_SPECIAL_SCHEMES = ("file", "ftp", "http", "https", "ws", "wss")
|
|
54
|
+
_SPECIAL_SCHEME_COLON = re.compile(
|
|
55
|
+
r"(?:" + "|".join(_SPECIAL_SCHEMES) + r"):",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
_ANY_SCHEME_AUTHORITY = re.compile(r"[A-Za-z][A-Za-z0-9+.\-]*:[/\\]")
|
|
59
|
+
_SCHEME_RELATIVE_AUTHORITY = re.compile(r"[/\\]{2}")
|
|
60
|
+
|
|
61
|
+
# Python 3.13 ships Unicode 15.1 data while the WHATWG consumer oracle already uses Unicode 16.
|
|
62
|
+
# Unicode 16 assigned the contiguous OUTLINED LATIN CAPITAL LETTER A..Z and OUTLINED DIGIT
|
|
63
|
+
# ZERO..NINE block at U+1CCD6..U+1CCF9, with compatibility decompositions to ASCII. Keep that
|
|
64
|
+
# complete delta explicit instead of branching on ``unicodedata.unidata_version``. Translation is
|
|
65
|
+
# idempotent when the runtime NFKC implementation already normalizes the block.
|
|
66
|
+
_OUTLINED_ALPHANUMERICS = "".join(chr(codepoint) for codepoint in range(0x1CCD6, 0x1CCFA))
|
|
67
|
+
_OUTLINED_ALPHANUMERIC_ASCII = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
68
|
+
_UNICODE_16_NFKC_DELTA = str.maketrans(
|
|
69
|
+
_OUTLINED_ALPHANUMERICS,
|
|
70
|
+
_OUTLINED_ALPHANUMERIC_ASCII,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _consumer_nfkc(text: str) -> str:
|
|
75
|
+
"""NFKC plus the pinned Unicode 16 delta used by current URL consumers."""
|
|
76
|
+
|
|
77
|
+
return unicodedata.normalize("NFKC", text).translate(_UNICODE_16_NFKC_DELTA)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _characters_a_url_parser_deletes() -> str:
|
|
81
|
+
"""Return C0 characters that the active URL parser removes from a hostname.
|
|
82
|
+
|
|
83
|
+
Deriving the set from the parser avoids a duplicate parser table.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
deleted: list[str] = []
|
|
87
|
+
for code in range(0x21):
|
|
88
|
+
character = chr(code)
|
|
89
|
+
try:
|
|
90
|
+
reads = urlsplit(f"https://a{character}b/").hostname
|
|
91
|
+
except ValueError:
|
|
92
|
+
continue
|
|
93
|
+
if reads == "ab":
|
|
94
|
+
deleted.append(character)
|
|
95
|
+
return "".join(deleted)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Deletion can assemble a scheme token that was not contiguous in the written form, so the prose
|
|
99
|
+
# predicate checks the parser-deleted form as well.
|
|
100
|
+
_PARSER_DELETES = _characters_a_url_parser_deletes()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _the_deletions_a_url_parser_makes(text: str) -> tuple[str, frozenset[int]]:
|
|
104
|
+
"""``text`` with the deleted characters gone, and the positions they were deleted from.
|
|
105
|
+
|
|
106
|
+
One pass that reports its own seams, because the rest of this module has to be able to tell a
|
|
107
|
+
character that was *written* next to another one from a character a deletion pulled up against
|
|
108
|
+
it. A seam at index ``i`` of the returned text means at least one deleted character stood
|
|
109
|
+
between ``text[i - 1]`` and ``text[i]`` as the author wrote them.
|
|
110
|
+
|
|
111
|
+
A deleted character can appear before a scheme token or inside the scheme name, as in
|
|
112
|
+
``ht<LF>tps:``. The written form and the deletion-normalized form cannot independently preserve
|
|
113
|
+
that boundary; the seam does so for every parser-deleted character and position.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
kept: list[str] = []
|
|
117
|
+
seams: set[int] = set()
|
|
118
|
+
for character in text:
|
|
119
|
+
if character in _PARSER_DELETES:
|
|
120
|
+
seams.add(len(kept))
|
|
121
|
+
else:
|
|
122
|
+
kept.append(character)
|
|
123
|
+
return "".join(kept), frozenset(seams)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class CatalogPrivacyError(SourceContractError):
|
|
127
|
+
"""A refusal taken to keep tenant-private or non-public content out of the catalog.
|
|
128
|
+
|
|
129
|
+
It subclasses ``SourceContractError`` so existing fail-closed handling still catches it, and
|
|
130
|
+
is its own type so a privacy refusal is never mistaken for an ordinary validation failure in
|
|
131
|
+
a log or a test.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def admit_public_fact_bytes(
|
|
136
|
+
content: bytes,
|
|
137
|
+
*,
|
|
138
|
+
lawful_basis_declared: bool = True,
|
|
139
|
+
declared_classification: str = CATALOG_CLASSIFICATION,
|
|
140
|
+
) -> ClassificationResult:
|
|
141
|
+
"""Admit bounded public bytes as catalog facts, or refuse them with a typed error.
|
|
142
|
+
|
|
143
|
+
``declared_classification`` exists only so a caller that passes the wrong thing gets a loud
|
|
144
|
+
refusal instead of quietly widening the catalog. There is no value of it that admits
|
|
145
|
+
non-public content.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
if not isinstance(content, bytes):
|
|
149
|
+
raise SourceContractError(
|
|
150
|
+
"TYPE",
|
|
151
|
+
"catalog_fact.content",
|
|
152
|
+
"catalog fact content must be bytes",
|
|
153
|
+
)
|
|
154
|
+
if len(content) > MAX_CATALOG_FACT_BYTES:
|
|
155
|
+
raise SourceContractError(
|
|
156
|
+
"CATALOG_FACT_LIMIT",
|
|
157
|
+
"catalog_fact.content",
|
|
158
|
+
f"catalog fact input exceeds {MAX_CATALOG_FACT_BYTES} bytes",
|
|
159
|
+
)
|
|
160
|
+
if declared_classification != CATALOG_CLASSIFICATION:
|
|
161
|
+
raise CatalogPrivacyError(
|
|
162
|
+
"CATALOG_NON_PUBLIC_CLASSIFICATION",
|
|
163
|
+
"catalog_fact.declared_classification",
|
|
164
|
+
"the shared catalog admits public content and nothing else",
|
|
165
|
+
)
|
|
166
|
+
return classify_and_require_admissible(
|
|
167
|
+
content,
|
|
168
|
+
declared_classification=CATALOG_CLASSIFICATION,
|
|
169
|
+
lawful_basis_declared=lawful_basis_declared,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def entry_locators(entry: CatalogEntry) -> tuple[tuple[str, str], ...]:
|
|
174
|
+
"""Every typed evidence locator on this entry that has to name a public host.
|
|
175
|
+
|
|
176
|
+
Derived from the entry rather than written out, so this is what the check enumerates *and*
|
|
177
|
+
what a test can assert the check covers. A hand-written list is checkable only against another
|
|
178
|
+
hand-written list, which proves the two lists agree and nothing about the object.
|
|
179
|
+
|
|
180
|
+
An ``EvidenceReference.uri`` is a locator by type: every one of them, wherever it sits on the
|
|
181
|
+
entry, is walked here. ``rights.human_decision_reference`` is free text, not a typed locator;
|
|
182
|
+
opaque ticket IDs remain prose and a URL-shaped value is refused by the prose contract.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
if not isinstance(entry, CatalogEntry):
|
|
186
|
+
raise SourceContractError("TYPE", "entry", "must be a CatalogEntry")
|
|
187
|
+
|
|
188
|
+
found: list[tuple[str, str]] = []
|
|
189
|
+
for path, references in (
|
|
190
|
+
("catalog_entry.evidence", entry.evidence),
|
|
191
|
+
("catalog_entry.rights.evidence", entry.rights.evidence),
|
|
192
|
+
("catalog_entry.observation.evidence", entry.observation.evidence),
|
|
193
|
+
):
|
|
194
|
+
found.extend(
|
|
195
|
+
(f"{path}[{index}].uri", reference.uri) for index, reference in enumerate(references)
|
|
196
|
+
)
|
|
197
|
+
return tuple(found)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class _SchemeFormLimit(ValueError):
|
|
201
|
+
"""The conservative transform closure exceeded its fail-closed work budget."""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_MAX_SCHEME_FORMS = 64
|
|
205
|
+
_MAX_SCHEME_FORM_CHARACTERS = 256 * 1024
|
|
206
|
+
_FORM_LIMIT_TOKEN = "an encoded locator shape"
|
|
207
|
+
_UNASSIGNED_CODEPOINT_TOKEN = "a runtime-unassigned Unicode code point"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _scheme_forms(text: str) -> tuple[str, ...]:
|
|
211
|
+
"""The bounded fixed-point closure of forms a URL consumer may expose.
|
|
212
|
+
|
|
213
|
+
Percent-decoding, Unicode compatibility normalisation, and parser deletions do not commute.
|
|
214
|
+
Applying each operation only to the written string misses compositions such as a full-width
|
|
215
|
+
percent sign that becomes an escape only after NFKC. The worklist therefore applies every
|
|
216
|
+
transform to every newly discovered form until no form changes.
|
|
217
|
+
|
|
218
|
+
Catalog strings are already bounded by their contracts. The independent form-count and
|
|
219
|
+
aggregate-character budgets make that bound explicit here too; exceeding either budget is a
|
|
220
|
+
fail-closed prose refusal, never permission to stop looking.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
if not isinstance(text, str):
|
|
224
|
+
raise SourceContractError("TYPE", "catalog_entry.prose", "must be text")
|
|
225
|
+
|
|
226
|
+
forms: list[str] = [text]
|
|
227
|
+
seen = {text}
|
|
228
|
+
total_characters = len(text)
|
|
229
|
+
position = 0
|
|
230
|
+
while position < len(forms):
|
|
231
|
+
current = forms[position]
|
|
232
|
+
position += 1
|
|
233
|
+
deleted, _ = _the_deletions_a_url_parser_makes(current)
|
|
234
|
+
for transformed in (
|
|
235
|
+
unquote(current),
|
|
236
|
+
_consumer_nfkc(current),
|
|
237
|
+
deleted,
|
|
238
|
+
):
|
|
239
|
+
if transformed in seen:
|
|
240
|
+
continue
|
|
241
|
+
if (
|
|
242
|
+
len(forms) >= _MAX_SCHEME_FORMS
|
|
243
|
+
or total_characters + len(transformed) > _MAX_SCHEME_FORM_CHARACTERS
|
|
244
|
+
):
|
|
245
|
+
raise _SchemeFormLimit
|
|
246
|
+
seen.add(transformed)
|
|
247
|
+
forms.append(transformed)
|
|
248
|
+
total_characters += len(transformed)
|
|
249
|
+
return tuple(forms)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _scheme_token_a_parser_could_read(
|
|
253
|
+
text: str,
|
|
254
|
+
*,
|
|
255
|
+
include_scheme_relative: bool = False,
|
|
256
|
+
) -> str | None:
|
|
257
|
+
"""Return one conservative refusal token from any transformed form of the text.
|
|
258
|
+
|
|
259
|
+
Unassigned code points have no stable compatibility normalization. Refuse them rather than
|
|
260
|
+
admit a locator under a different Unicode version.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
forms = _scheme_forms(text)
|
|
265
|
+
except _SchemeFormLimit:
|
|
266
|
+
return _FORM_LIMIT_TOKEN
|
|
267
|
+
|
|
268
|
+
for form in forms:
|
|
269
|
+
if unassigned := next(
|
|
270
|
+
(character for character in form if unicodedata.category(character) == "Cn"),
|
|
271
|
+
None,
|
|
272
|
+
):
|
|
273
|
+
return f"{_UNASSIGNED_CODEPOINT_TOKEN} U+{ord(unassigned):04X}"
|
|
274
|
+
patterns = (_SPECIAL_SCHEME_COLON, _ANY_SCHEME_AUTHORITY)
|
|
275
|
+
if include_scheme_relative:
|
|
276
|
+
patterns += (_SCHEME_RELATIVE_AUTHORITY,)
|
|
277
|
+
matches = tuple(
|
|
278
|
+
match for pattern in patterns if (match := pattern.search(form)) is not None
|
|
279
|
+
)
|
|
280
|
+
if matches:
|
|
281
|
+
return min(matches, key=lambda match: match.start()).group(0)
|
|
282
|
+
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _a_parser_could_read_a_scheme_from(text: str) -> bool:
|
|
286
|
+
"""Return whether conservative prose admission finds a scheme or unknown normalization."""
|
|
287
|
+
|
|
288
|
+
return _scheme_token_a_parser_could_read(text) is not None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def entry_prose_carrying_a_locator(entry: CatalogEntry) -> tuple[tuple[str, str], ...]:
|
|
292
|
+
"""Every path and scheme token whose prose must be refused.
|
|
293
|
+
|
|
294
|
+
The walk is over the serialized entry rather than a field list. Typed locator values are
|
|
295
|
+
excluded as whole values and checked by _require_public_citation; their path, query, and
|
|
296
|
+
fragment components receive this same scheme-shape rule there so a nested locator cannot move
|
|
297
|
+
from prose into a citation to evade it.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
if not isinstance(entry, CatalogEntry):
|
|
301
|
+
raise SourceContractError("TYPE", "entry", "must be a CatalogEntry")
|
|
302
|
+
locators = set(entry_locators(entry))
|
|
303
|
+
found: list[tuple[str, str]] = []
|
|
304
|
+
for path, text in _strings_in(entry.to_dict(), "catalog_entry"):
|
|
305
|
+
if (path, text) in locators:
|
|
306
|
+
continue
|
|
307
|
+
if (token := _scheme_token_a_parser_could_read(text)) is not None:
|
|
308
|
+
found.append((path, token))
|
|
309
|
+
return tuple(found)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _strings_in(node: Any, path: str) -> Iterator[tuple[str, str]]:
|
|
313
|
+
"""Every string key and value under ``node``, with its canonical path.
|
|
314
|
+
|
|
315
|
+
Keys are visited in sorted order so the reported path order is the order the canonical JSON
|
|
316
|
+
writer would use, and so a refusal names the same field on every run.
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
if isinstance(node, dict):
|
|
320
|
+
for key in sorted(node):
|
|
321
|
+
# Catalog contract keys are closed, but they are serialized bytes too. Scanning
|
|
322
|
+
# them keeps this walk sound for dynamic-key mappings.
|
|
323
|
+
yield f"{path}.<key>", key
|
|
324
|
+
yield from _strings_in(node[key], f"{path}.{key}")
|
|
325
|
+
elif isinstance(node, list):
|
|
326
|
+
for index, item in enumerate(node):
|
|
327
|
+
yield from _strings_in(item, f"{path}[{index}]")
|
|
328
|
+
elif isinstance(node, str):
|
|
329
|
+
yield path, node
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def require_public_source_evidence(entry: CatalogEntry, *, egress_policy: EgressPolicy) -> None:
|
|
333
|
+
"""Refuse entries with unsafe typed locators or any locator-shaped prose.
|
|
334
|
+
|
|
335
|
+
Typed locators must be HTTPS, host-check clean, and contain no nested locator in a path, query,
|
|
336
|
+
or fragment. Every other serialized string -- including mapping keys -- is prose and is
|
|
337
|
+
refused when the conservative transform closure exposes a scheme shape. There is no prose
|
|
338
|
+
host allowlist: links belong in typed citation fields.
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
if not isinstance(egress_policy, EgressPolicy):
|
|
342
|
+
raise SourceContractError("TYPE", "egress_policy", "must be an EgressPolicy")
|
|
343
|
+
for path, uri in entry_locators(entry):
|
|
344
|
+
_require_public_citation(uri, path, egress_policy=egress_policy)
|
|
345
|
+
if carrying := entry_prose_carrying_a_locator(entry):
|
|
346
|
+
path, token = carrying[0]
|
|
347
|
+
raise CatalogPrivacyError(
|
|
348
|
+
"CATALOG_PROSE_CARRIES_LOCATOR",
|
|
349
|
+
path,
|
|
350
|
+
f"catalog prose carries unsafe locator material {token!r}; put locators in a typed "
|
|
351
|
+
"citation field and remove runtime-unassigned characters",
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _require_public_citation(uri: str, path: str, *, egress_policy: EgressPolicy) -> None:
|
|
356
|
+
try:
|
|
357
|
+
parsed = urlsplit(uri)
|
|
358
|
+
hostname = parsed.hostname
|
|
359
|
+
except ValueError:
|
|
360
|
+
raise CatalogPrivacyError(
|
|
361
|
+
"CATALOG_EVIDENCE_NOT_PUBLIC",
|
|
362
|
+
path,
|
|
363
|
+
"citation is not a parsable URL",
|
|
364
|
+
) from None
|
|
365
|
+
if parsed.scheme != "https":
|
|
366
|
+
raise CatalogPrivacyError(
|
|
367
|
+
"CATALOG_EVIDENCE_NOT_PUBLIC",
|
|
368
|
+
path,
|
|
369
|
+
"citations must be HTTPS",
|
|
370
|
+
)
|
|
371
|
+
if not hostname:
|
|
372
|
+
raise CatalogPrivacyError(
|
|
373
|
+
"CATALOG_EVIDENCE_NOT_PUBLIC",
|
|
374
|
+
path,
|
|
375
|
+
"citations must name a host",
|
|
376
|
+
)
|
|
377
|
+
_require_no_nested_locator(parsed, path)
|
|
378
|
+
_require_public_host(
|
|
379
|
+
parsed,
|
|
380
|
+
path,
|
|
381
|
+
egress_policy=egress_policy,
|
|
382
|
+
not_public="CATALOG_EVIDENCE_NOT_PUBLIC",
|
|
383
|
+
not_allowlisted="CATALOG_EVIDENCE_NOT_ALLOWLISTED",
|
|
384
|
+
subject="this citation",
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _require_no_nested_locator(parsed: SplitResult, path: str) -> None:
|
|
389
|
+
"""Refuse a locator hidden in any non-authority component of a typed locator."""
|
|
390
|
+
|
|
391
|
+
for component_name, component in (
|
|
392
|
+
("path", parsed.path),
|
|
393
|
+
("query", parsed.query),
|
|
394
|
+
("fragment", parsed.fragment),
|
|
395
|
+
):
|
|
396
|
+
if (
|
|
397
|
+
token := _scheme_token_a_parser_could_read(
|
|
398
|
+
component,
|
|
399
|
+
include_scheme_relative=True,
|
|
400
|
+
)
|
|
401
|
+
) is not None:
|
|
402
|
+
raise CatalogPrivacyError(
|
|
403
|
+
"CATALOG_EVIDENCE_CARRIES_NESTED_LOCATOR",
|
|
404
|
+
path,
|
|
405
|
+
f"citation {component_name} carries unsafe locator material {token!r}; a typed "
|
|
406
|
+
"citation names exactly one stable locator",
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _require_public_host(
|
|
411
|
+
parsed: SplitResult,
|
|
412
|
+
path: str,
|
|
413
|
+
*,
|
|
414
|
+
egress_policy: EgressPolicy,
|
|
415
|
+
not_public: str,
|
|
416
|
+
not_allowlisted: str,
|
|
417
|
+
subject: str,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Apply the canonical public-host rule to the complete parsed locator.
|
|
420
|
+
|
|
421
|
+
Passing the complete locator lets ``canonical_public_hostname`` reject credentials and
|
|
422
|
+
authority delimiters before it normalizes the hostname through IDNA. The allowlist is applied
|
|
423
|
+
only to that canonical name, so every typed citation and serialized locator follows the same
|
|
424
|
+
consumer-compatible rule.
|
|
425
|
+
|
|
426
|
+
It costs false refusals, and they are stated on the customer surface rather than discovered:
|
|
427
|
+
a label over 63 characters, an empty label (``https://.example.org``, ``https://a..b``), a
|
|
428
|
+
label carrying an underscore or a leading or trailing hyphen, and a name over 253 characters
|
|
429
|
+
are all refused. None of them is a name the public DNS resolves; the escape in every case is
|
|
430
|
+
to write the source's real machine name.
|
|
431
|
+
"""
|
|
432
|
+
|
|
433
|
+
try:
|
|
434
|
+
host = canonical_public_hostname(parsed)
|
|
435
|
+
except AcquisitionSecurityError:
|
|
436
|
+
raise CatalogPrivacyError(
|
|
437
|
+
not_public,
|
|
438
|
+
path,
|
|
439
|
+
f"{subject} does not spell a machine name a URL parser would resolve, so this module "
|
|
440
|
+
"cannot say which host it names. A catalog names public hosts, and a name it cannot "
|
|
441
|
+
"read is not one",
|
|
442
|
+
) from None
|
|
443
|
+
# An IP literal is never a catalog link. Refusing the whole shape covers loopback, the
|
|
444
|
+
# private ranges and the cloud metadata address in one rule rather than enumerating them.
|
|
445
|
+
try:
|
|
446
|
+
ipaddress.ip_address(host)
|
|
447
|
+
except ValueError:
|
|
448
|
+
pass
|
|
449
|
+
else:
|
|
450
|
+
raise CatalogPrivacyError(
|
|
451
|
+
not_public,
|
|
452
|
+
path,
|
|
453
|
+
f"{subject} names an address; a catalog names public hosts, never addresses",
|
|
454
|
+
)
|
|
455
|
+
if "." not in host or host in _FORBIDDEN_HOST_NAMES or host.endswith(_FORBIDDEN_HOST_SUFFIXES):
|
|
456
|
+
raise CatalogPrivacyError(
|
|
457
|
+
not_public,
|
|
458
|
+
path,
|
|
459
|
+
f"{subject} names a single-label host or an internal namespace",
|
|
460
|
+
)
|
|
461
|
+
if not egress_policy.permits(host, _CITATION_PORT):
|
|
462
|
+
raise CatalogPrivacyError(
|
|
463
|
+
not_allowlisted,
|
|
464
|
+
path,
|
|
465
|
+
f"{subject} names a host the coordinator has not allowlisted",
|
|
466
|
+
)
|
|
467
|
+
# Last, so a link that is wrong about its host as well as carrying userinfo is still refused
|
|
468
|
+
# for its host: which host a link names is the more useful thing for a refusal to say, and
|
|
469
|
+
# `https://public.example@internal.corp/x` is a host problem wearing a userinfo disguise.
|
|
470
|
+
if parsed.username is not None or parsed.password is not None:
|
|
471
|
+
raise CatalogPrivacyError(
|
|
472
|
+
not_public,
|
|
473
|
+
path,
|
|
474
|
+
f"{subject} carries user information before its host. A public source is addressed "
|
|
475
|
+
"without credentials, and whatever is written there -- a token, a service account, an "
|
|
476
|
+
"internal name -- reaches every tenant verbatim in the shared catalog",
|
|
477
|
+
)
|