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,631 @@
|
|
|
1
|
+
"""Effective credential resolution and one-way protected-store migration.
|
|
2
|
+
|
|
3
|
+
``~/.mostlyright/credentials`` is either the strict seven-field legacy object (only until verified
|
|
4
|
+
migration) or a two-field non-secret backend manifest. Raw keys otherwise exist only in the
|
|
5
|
+
selected protected backend and in the explicitly redacted values returned to cloud transports.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import os
|
|
12
|
+
import secrets
|
|
13
|
+
import stat
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from mostlyright.data_harness.canonical import (
|
|
20
|
+
CanonicalJSONError,
|
|
21
|
+
canonical_json_bytes,
|
|
22
|
+
parse_canonical_json,
|
|
23
|
+
)
|
|
24
|
+
from mostlyright.data_harness.key_seam import LEGACY_API_KEY_ENV
|
|
25
|
+
from mostlyright.data_harness.ux.credential_native import native_backend
|
|
26
|
+
from mostlyright.data_harness.ux.credential_store import (
|
|
27
|
+
CredentialBackend,
|
|
28
|
+
CredentialEnvelope,
|
|
29
|
+
CredentialStore,
|
|
30
|
+
CredentialStoreError,
|
|
31
|
+
ProtectedCredential,
|
|
32
|
+
SecureFileBackend,
|
|
33
|
+
_windows_acl_is_private,
|
|
34
|
+
credential_lock,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
CREDENTIALS_DIR_NAME = ".mostlyright"
|
|
38
|
+
CREDENTIALS_FILENAME = "credentials"
|
|
39
|
+
MAX_CREDENTIALS_FILE_BYTES = 64 * 1024
|
|
40
|
+
CREDENTIAL_MANIFEST_SCHEMA = "mr-data-credential-manifest.v1"
|
|
41
|
+
|
|
42
|
+
_REQUIRED_FIELDS = frozenset(
|
|
43
|
+
{"cloud_url", "key_id", "prefix", "last_four", "device_name", "created_at", "raw_key"}
|
|
44
|
+
)
|
|
45
|
+
_MANIFEST_FIELDS = frozenset({"schema_version", "backend"})
|
|
46
|
+
_BACKENDS = frozenset({"keychain", "dpapi", "secret-service", "secure-file"})
|
|
47
|
+
|
|
48
|
+
CLOUD_URL_ENV = "MOSTLYRIGHT_CLOUD_URL"
|
|
49
|
+
DEFAULT_CLOUD_URL = "https://mostlyright.md"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class LoginError(RuntimeError):
|
|
53
|
+
"""A fail-closed login/storage failure whose detail is always safe to print."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, code: str, detail: str) -> None:
|
|
56
|
+
super().__init__(f"{code}: {detail}")
|
|
57
|
+
self.code = code
|
|
58
|
+
self.detail = detail
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class StoredCredentials:
|
|
63
|
+
"""One stored device credential with an always-redacted representation."""
|
|
64
|
+
|
|
65
|
+
cloud_url: str
|
|
66
|
+
key_id: str
|
|
67
|
+
prefix: str
|
|
68
|
+
last_four: str
|
|
69
|
+
device_name: str
|
|
70
|
+
created_at: str
|
|
71
|
+
raw_key: str
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
return (
|
|
75
|
+
"StoredCredentials("
|
|
76
|
+
f"cloud_url={self.cloud_url!r}, key_id={self.key_id!r}, prefix={self.prefix!r}, "
|
|
77
|
+
f"last_four={self.last_four!r}, device_name={self.device_name!r}, "
|
|
78
|
+
f"created_at={self.created_at!r}, raw_key='<redacted>')"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def to_receipt(self) -> dict[str, Any]:
|
|
82
|
+
return {
|
|
83
|
+
"cloud_url": self.cloud_url,
|
|
84
|
+
"key_id": self.key_id,
|
|
85
|
+
"prefix": self.prefix,
|
|
86
|
+
"last_four": self.last_four,
|
|
87
|
+
"device_name": self.device_name,
|
|
88
|
+
"created_at": self.created_at,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def _protected(self) -> ProtectedCredential:
|
|
92
|
+
try:
|
|
93
|
+
return ProtectedCredential(
|
|
94
|
+
cloud_url=self.cloud_url,
|
|
95
|
+
key_id=self.key_id,
|
|
96
|
+
prefix=self.prefix,
|
|
97
|
+
last_four=self.last_four,
|
|
98
|
+
device_name=self.device_name,
|
|
99
|
+
created_at=self.created_at,
|
|
100
|
+
raw_key=self.raw_key,
|
|
101
|
+
)
|
|
102
|
+
except CredentialStoreError as error:
|
|
103
|
+
raise LoginError(
|
|
104
|
+
"LOGIN_CREDENTIALS_UNREADABLE", "the credential contains an invalid field"
|
|
105
|
+
) from error
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def _from_protected(cls, value: ProtectedCredential) -> StoredCredentials:
|
|
109
|
+
return cls(
|
|
110
|
+
cloud_url=value.cloud_url,
|
|
111
|
+
key_id=value.key_id,
|
|
112
|
+
prefix=value.prefix,
|
|
113
|
+
last_four=value.last_four,
|
|
114
|
+
device_name=value.device_name,
|
|
115
|
+
created_at=value.created_at,
|
|
116
|
+
raw_key=value.raw_key,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class ResolvedCloudCredentials:
|
|
122
|
+
"""One raw transport credential with safe receipt and representation projections."""
|
|
123
|
+
|
|
124
|
+
cloud_url: str
|
|
125
|
+
raw_key: str
|
|
126
|
+
source: str
|
|
127
|
+
|
|
128
|
+
def __repr__(self) -> str:
|
|
129
|
+
return (
|
|
130
|
+
"ResolvedCloudCredentials("
|
|
131
|
+
f"cloud_url={self.cloud_url!r}, raw_key='<redacted>', source={self.source!r})"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def to_receipt(self) -> dict[str, str]:
|
|
135
|
+
return {"cloud_url": self.cloud_url, "source": self.source}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def default_credentials_dir() -> Path:
|
|
139
|
+
return Path.home() / CREDENTIALS_DIR_NAME
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def credentials_path(config_dir: Path | None = None) -> Path:
|
|
143
|
+
return (config_dir or default_credentials_dir()) / CREDENTIALS_FILENAME
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def refuse_existing_credentials(*, config_dir: Path | None = None) -> None:
|
|
147
|
+
path = credentials_path(config_dir)
|
|
148
|
+
if os.path.lexists(path):
|
|
149
|
+
raise LoginError(
|
|
150
|
+
"LOGIN_CREDENTIALS_EXIST",
|
|
151
|
+
f"a stored credential already exists at {path}; pass --force to overwrite it",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def store_credentials(
|
|
156
|
+
credentials: StoredCredentials,
|
|
157
|
+
*,
|
|
158
|
+
config_dir: Path | None = None,
|
|
159
|
+
force: bool = False,
|
|
160
|
+
credential_store: str | None = None,
|
|
161
|
+
) -> Path:
|
|
162
|
+
"""Install one active credential in the selected protected backend."""
|
|
163
|
+
|
|
164
|
+
directory = config_dir or default_credentials_dir()
|
|
165
|
+
path = directory / CREDENTIALS_FILENAME
|
|
166
|
+
selector = _selector(credential_store)
|
|
167
|
+
try:
|
|
168
|
+
with credential_lock(directory):
|
|
169
|
+
if os.path.lexists(path) and not force:
|
|
170
|
+
# This remains a second direct raise site because the preflight above is only a
|
|
171
|
+
# courtesy check; a competing writer may win between it and this locked gate.
|
|
172
|
+
raise LoginError(
|
|
173
|
+
"LOGIN_CREDENTIALS_EXIST",
|
|
174
|
+
f"a stored credential already exists at {path}; pass --force to overwrite it",
|
|
175
|
+
)
|
|
176
|
+
control = _read_control(path)
|
|
177
|
+
if control is None:
|
|
178
|
+
backend = _backend_for(directory, selector or _native_backend_name(directory))
|
|
179
|
+
current = None
|
|
180
|
+
else:
|
|
181
|
+
kind, value = _parse_control(control, path)
|
|
182
|
+
if kind == "legacy":
|
|
183
|
+
backend = _backend_for(directory, selector or _native_backend_name(directory))
|
|
184
|
+
current = None
|
|
185
|
+
else:
|
|
186
|
+
backend_name = value["backend"]
|
|
187
|
+
_require_selector_agrees(selector, backend_name)
|
|
188
|
+
backend = _backend_for(directory, backend_name)
|
|
189
|
+
current = _load_envelope(directory, backend)
|
|
190
|
+
generation = 1 if current is None else current.generation + 1
|
|
191
|
+
envelope = CredentialEnvelope.active_envelope(
|
|
192
|
+
generation=generation,
|
|
193
|
+
active=credentials._protected(),
|
|
194
|
+
)
|
|
195
|
+
_write_envelope(directory, backend, envelope)
|
|
196
|
+
_require_readback(directory, backend, envelope)
|
|
197
|
+
_install_manifest(path, backend.name)
|
|
198
|
+
return path
|
|
199
|
+
except LoginError:
|
|
200
|
+
raise
|
|
201
|
+
except CredentialStoreError as error:
|
|
202
|
+
raise _login_store_error(error) from None
|
|
203
|
+
except OSError:
|
|
204
|
+
raise LoginError(
|
|
205
|
+
"LOGIN_CREDENTIAL_MIGRATION_FAILED",
|
|
206
|
+
f"the credential manifest at {path} could not be installed",
|
|
207
|
+
) from None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def read_credentials(
|
|
211
|
+
*,
|
|
212
|
+
config_dir: Path | None = None,
|
|
213
|
+
credential_store: str | None = None,
|
|
214
|
+
) -> StoredCredentials | None:
|
|
215
|
+
"""Read active protected credentials, migrating an exact safe legacy file once."""
|
|
216
|
+
|
|
217
|
+
directory = config_dir or default_credentials_dir()
|
|
218
|
+
path = directory / CREDENTIALS_FILENAME
|
|
219
|
+
selector = _selector(credential_store)
|
|
220
|
+
if not os.path.lexists(path):
|
|
221
|
+
return None
|
|
222
|
+
try:
|
|
223
|
+
with credential_lock(directory):
|
|
224
|
+
raw = _read_control(path)
|
|
225
|
+
if raw is None:
|
|
226
|
+
return None
|
|
227
|
+
kind, value = _parse_control(raw, path)
|
|
228
|
+
if kind == "manifest":
|
|
229
|
+
backend_name = value["backend"]
|
|
230
|
+
_require_selector_agrees(selector, backend_name)
|
|
231
|
+
envelope = _load_envelope(directory, _backend_for(directory, backend_name))
|
|
232
|
+
return _active_credentials(envelope)
|
|
233
|
+
|
|
234
|
+
legacy = _legacy_credentials(value, path)
|
|
235
|
+
backend = _backend_for(directory, selector or _native_backend_name(directory))
|
|
236
|
+
envelope = CredentialEnvelope.active_envelope(
|
|
237
|
+
generation=1,
|
|
238
|
+
active=legacy._protected(),
|
|
239
|
+
)
|
|
240
|
+
_write_envelope(directory, backend, envelope)
|
|
241
|
+
_require_readback(directory, backend, envelope)
|
|
242
|
+
try:
|
|
243
|
+
_install_manifest(path, backend.name)
|
|
244
|
+
except OSError:
|
|
245
|
+
raise LoginError(
|
|
246
|
+
"LOGIN_CREDENTIAL_MIGRATION_FAILED",
|
|
247
|
+
f"the legacy credential at {path} remains recoverable because migration "
|
|
248
|
+
"could not finish",
|
|
249
|
+
) from None
|
|
250
|
+
return legacy
|
|
251
|
+
except LoginError:
|
|
252
|
+
raise
|
|
253
|
+
except CredentialStoreError as error:
|
|
254
|
+
raise _login_store_error(error) from None
|
|
255
|
+
except OSError:
|
|
256
|
+
raise LoginError(
|
|
257
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
258
|
+
f"the stored credential at {path} could not be read",
|
|
259
|
+
) from None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def resolve_cloud_credentials(
|
|
263
|
+
*,
|
|
264
|
+
environment: Mapping[str, str] | None = None,
|
|
265
|
+
config_dir: Path | None = None,
|
|
266
|
+
credential_store: str | None = None,
|
|
267
|
+
) -> ResolvedCloudCredentials:
|
|
268
|
+
values = os.environ if environment is None else environment
|
|
269
|
+
if LEGACY_API_KEY_ENV in values:
|
|
270
|
+
raw_key = values[LEGACY_API_KEY_ENV]
|
|
271
|
+
if not isinstance(raw_key, str) or not raw_key.strip():
|
|
272
|
+
raise LoginError(
|
|
273
|
+
"LOGIN_API_KEY_INVALID",
|
|
274
|
+
f"{LEGACY_API_KEY_ENV} is set but does not contain a credential",
|
|
275
|
+
)
|
|
276
|
+
return ResolvedCloudCredentials(
|
|
277
|
+
cloud_url=values.get(CLOUD_URL_ENV) or DEFAULT_CLOUD_URL,
|
|
278
|
+
raw_key=raw_key,
|
|
279
|
+
source="environment",
|
|
280
|
+
)
|
|
281
|
+
stored = read_credentials(config_dir=config_dir, credential_store=credential_store)
|
|
282
|
+
if stored is None:
|
|
283
|
+
raise LoginError(
|
|
284
|
+
"LOGIN_REQUIRED",
|
|
285
|
+
"this cloud command needs a device credential; run mr-data login first",
|
|
286
|
+
)
|
|
287
|
+
return ResolvedCloudCredentials(
|
|
288
|
+
cloud_url=stored.cloud_url,
|
|
289
|
+
raw_key=stored.raw_key,
|
|
290
|
+
source="stored_device",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def credential_transaction_store(
|
|
295
|
+
*,
|
|
296
|
+
config_dir: Path | None = None,
|
|
297
|
+
credential_store: str | None = None,
|
|
298
|
+
) -> tuple[CredentialStore, Path] | None:
|
|
299
|
+
"""Return the selected protected store and its non-secret manifest path.
|
|
300
|
+
|
|
301
|
+
Mutating lifecycle commands use this narrow helper rather than reimplementing backend
|
|
302
|
+
selection. A legacy control file is migrated through the existing verified migration path
|
|
303
|
+
before its backend is returned; a pending envelope is deliberately returned rather than
|
|
304
|
+
flattened through :func:`read_credentials`.
|
|
305
|
+
"""
|
|
306
|
+
|
|
307
|
+
directory = config_dir or default_credentials_dir()
|
|
308
|
+
path = directory / CREDENTIALS_FILENAME
|
|
309
|
+
selector = _selector(credential_store)
|
|
310
|
+
if not os.path.lexists(path):
|
|
311
|
+
backend = _backend_for(directory, selector or _native_backend_name(directory))
|
|
312
|
+
return CredentialStore(directory, backend), path
|
|
313
|
+
raw = _read_control(path)
|
|
314
|
+
if raw is None:
|
|
315
|
+
return None
|
|
316
|
+
kind, value = _parse_control(raw, path)
|
|
317
|
+
if kind == "legacy":
|
|
318
|
+
# This call owns the migration lock and preserves the legacy bytes on every failure.
|
|
319
|
+
read_credentials(config_dir=directory, credential_store=selector)
|
|
320
|
+
raw = _read_control(path)
|
|
321
|
+
if raw is None:
|
|
322
|
+
return None
|
|
323
|
+
kind, value = _parse_control(raw, path)
|
|
324
|
+
if kind != "manifest":
|
|
325
|
+
raise LoginError("LOGIN_CREDENTIALS_UNREADABLE", "the stored credential is unreadable")
|
|
326
|
+
backend_name = value["backend"]
|
|
327
|
+
_require_selector_agrees(selector, backend_name)
|
|
328
|
+
return CredentialStore(directory, _backend_for(directory, backend_name)), path
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def remove_credential_manifest_under_held_lock(path: Path) -> None:
|
|
332
|
+
"""Delete the non-secret control record after protected cleanup succeeds.
|
|
333
|
+
|
|
334
|
+
Callers hold the matching per-config lock. Validate the exact regular manifest immediately
|
|
335
|
+
before unlinking so a lifecycle cleanup cannot remove a substituted filesystem object.
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
raw = _read_control(path)
|
|
339
|
+
if raw is None:
|
|
340
|
+
return
|
|
341
|
+
kind, _value = _parse_control(raw, path)
|
|
342
|
+
if kind != "manifest":
|
|
343
|
+
raise LoginError("LOGIN_CREDENTIALS_UNREADABLE", "the stored credential is unreadable")
|
|
344
|
+
try:
|
|
345
|
+
path.unlink()
|
|
346
|
+
except OSError:
|
|
347
|
+
raise LoginError(
|
|
348
|
+
"LOGIN_CREDENTIAL_MIGRATION_FAILED",
|
|
349
|
+
"the local credential manifest could not be removed",
|
|
350
|
+
) from None
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def install_credential_manifest(path: Path, backend_name: str) -> None:
|
|
354
|
+
"""Durably publish the non-secret backend selector after a protected write."""
|
|
355
|
+
|
|
356
|
+
_install_manifest(path, backend_name)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def whoami_payload(
|
|
360
|
+
*,
|
|
361
|
+
config_dir: Path | None = None,
|
|
362
|
+
credential_store: str | None = None,
|
|
363
|
+
) -> dict[str, Any]:
|
|
364
|
+
path = credentials_path(config_dir)
|
|
365
|
+
credentials = read_credentials(config_dir=config_dir, credential_store=credential_store)
|
|
366
|
+
if credentials is None:
|
|
367
|
+
return {
|
|
368
|
+
"schema_version": "mr-data-whoami.v1",
|
|
369
|
+
"status": "not_authenticated",
|
|
370
|
+
"credentials_path": str(path),
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
"schema_version": "mr-data-whoami.v1",
|
|
374
|
+
"status": "authenticated",
|
|
375
|
+
**credentials.to_receipt(),
|
|
376
|
+
"credentials_path": str(path),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _selector(value: str | None) -> str | None:
|
|
381
|
+
if value is None:
|
|
382
|
+
return None
|
|
383
|
+
if value != "secure-file":
|
|
384
|
+
raise LoginError(
|
|
385
|
+
"LOGIN_CREDENTIAL_STORE_INVALID",
|
|
386
|
+
"credential store must be the exact value secure-file when explicitly selected",
|
|
387
|
+
)
|
|
388
|
+
return value
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _native_backend_name(directory: Path) -> str:
|
|
392
|
+
try:
|
|
393
|
+
return native_backend(directory).name
|
|
394
|
+
except CredentialStoreError as error:
|
|
395
|
+
raise _login_store_error(error) from None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _backend_for(directory: Path, backend_name: str) -> CredentialBackend:
|
|
399
|
+
if backend_name == "secure-file":
|
|
400
|
+
return SecureFileBackend(directory, explicit=True)
|
|
401
|
+
try:
|
|
402
|
+
backend = native_backend(directory)
|
|
403
|
+
except CredentialStoreError as error:
|
|
404
|
+
raise _login_store_error(error) from None
|
|
405
|
+
if backend.name != backend_name:
|
|
406
|
+
raise LoginError(
|
|
407
|
+
"LOGIN_CREDENTIAL_STORE_UNAVAILABLE",
|
|
408
|
+
f"the credential manifest names {backend_name}, which is unavailable on this system",
|
|
409
|
+
)
|
|
410
|
+
return backend
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _require_selector_agrees(selector: str | None, backend_name: str) -> None:
|
|
414
|
+
if selector is not None and selector != backend_name:
|
|
415
|
+
raise LoginError(
|
|
416
|
+
"LOGIN_CREDENTIAL_STORE_CONFLICT",
|
|
417
|
+
f"the stored credential uses {backend_name}; the explicit selector does not agree",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _load_envelope(directory: Path, backend: CredentialBackend) -> CredentialEnvelope | None:
|
|
422
|
+
return CredentialStore(directory, backend).load_under_held_lock()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _write_envelope(
|
|
426
|
+
directory: Path,
|
|
427
|
+
backend: CredentialBackend,
|
|
428
|
+
envelope: CredentialEnvelope,
|
|
429
|
+
) -> None:
|
|
430
|
+
CredentialStore(directory, backend).write_under_held_lock(envelope)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _active_credentials(envelope: CredentialEnvelope | None) -> StoredCredentials | None:
|
|
434
|
+
if envelope is None:
|
|
435
|
+
return None
|
|
436
|
+
if envelope.state != "active" or envelope.active is None:
|
|
437
|
+
detail = (
|
|
438
|
+
"a device login is pending and its remote mint outcome is unproved"
|
|
439
|
+
if envelope.state == "login_pending"
|
|
440
|
+
else "the stored credential has a pending recovery operation"
|
|
441
|
+
)
|
|
442
|
+
raise LoginError("LOGIN_CREDENTIAL_RECOVERY_REQUIRED", detail)
|
|
443
|
+
return StoredCredentials._from_protected(envelope.active)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _require_readback(
|
|
447
|
+
directory: Path,
|
|
448
|
+
backend: CredentialBackend,
|
|
449
|
+
expected: CredentialEnvelope,
|
|
450
|
+
) -> None:
|
|
451
|
+
actual = _load_envelope(directory, backend)
|
|
452
|
+
if actual != expected or actual is None or actual.to_bytes() != expected.to_bytes():
|
|
453
|
+
raise CredentialStoreError(
|
|
454
|
+
"CREDENTIAL_STORE_READBACK_FAILED",
|
|
455
|
+
"the protected credential did not verify after write",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _read_control(path: Path) -> bytes | None:
|
|
460
|
+
try:
|
|
461
|
+
descriptor = os.open(
|
|
462
|
+
path,
|
|
463
|
+
os.O_RDONLY
|
|
464
|
+
| getattr(os, "O_BINARY", 0)
|
|
465
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
466
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
467
|
+
)
|
|
468
|
+
except FileNotFoundError:
|
|
469
|
+
if os.path.lexists(path):
|
|
470
|
+
raise LoginError(
|
|
471
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
472
|
+
f"the stored credential at {path} could not be read",
|
|
473
|
+
) from None
|
|
474
|
+
return None
|
|
475
|
+
except OSError:
|
|
476
|
+
raise LoginError(
|
|
477
|
+
"LOGIN_CREDENTIALS_UNREADABLE", f"the stored credential at {path} could not be read"
|
|
478
|
+
) from None
|
|
479
|
+
try:
|
|
480
|
+
info = os.fstat(descriptor)
|
|
481
|
+
valid = stat.S_ISREG(info.st_mode) and info.st_nlink == 1
|
|
482
|
+
if os.name == "posix":
|
|
483
|
+
valid = valid and info.st_uid == os.geteuid() and stat.S_IMODE(info.st_mode) == 0o600
|
|
484
|
+
elif os.name == "nt":
|
|
485
|
+
valid = valid and _windows_acl_is_private(path)
|
|
486
|
+
if not valid or info.st_size > MAX_CREDENTIALS_FILE_BYTES:
|
|
487
|
+
raise LoginError(
|
|
488
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
489
|
+
f"the stored credential at {path} could not be read",
|
|
490
|
+
)
|
|
491
|
+
raw = bytearray()
|
|
492
|
+
while len(raw) <= MAX_CREDENTIALS_FILE_BYTES:
|
|
493
|
+
chunk = os.read(descriptor, min(16 * 1024, MAX_CREDENTIALS_FILE_BYTES + 1 - len(raw)))
|
|
494
|
+
if not chunk:
|
|
495
|
+
break
|
|
496
|
+
raw.extend(chunk)
|
|
497
|
+
if len(raw) > MAX_CREDENTIALS_FILE_BYTES:
|
|
498
|
+
raise LoginError(
|
|
499
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
500
|
+
f"the stored credential at {path} could not be read",
|
|
501
|
+
)
|
|
502
|
+
return bytes(raw)
|
|
503
|
+
finally:
|
|
504
|
+
os.close(descriptor)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _parse_control(raw: bytes, path: Path) -> tuple[str, dict[str, Any]]:
|
|
508
|
+
try:
|
|
509
|
+
value = parse_canonical_json(raw)
|
|
510
|
+
except CanonicalJSONError:
|
|
511
|
+
raise LoginError(
|
|
512
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
513
|
+
f"the stored credential at {path} is not the file this command writes",
|
|
514
|
+
) from None
|
|
515
|
+
if not isinstance(value, dict):
|
|
516
|
+
raise LoginError(
|
|
517
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
518
|
+
f"the stored credential at {path} is not the file this command writes",
|
|
519
|
+
)
|
|
520
|
+
if set(value) == _MANIFEST_FIELDS:
|
|
521
|
+
if (
|
|
522
|
+
value.get("schema_version") != CREDENTIAL_MANIFEST_SCHEMA
|
|
523
|
+
or value.get("backend") not in _BACKENDS
|
|
524
|
+
):
|
|
525
|
+
raise LoginError(
|
|
526
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
527
|
+
f"the stored credential at {path} is not the file this command writes",
|
|
528
|
+
)
|
|
529
|
+
return "manifest", value
|
|
530
|
+
if set(value) == _REQUIRED_FIELDS:
|
|
531
|
+
return "legacy", value
|
|
532
|
+
raise LoginError(
|
|
533
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
534
|
+
f"the stored credential at {path} is not the file this command writes",
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _legacy_credentials(value: dict[str, Any], path: Path) -> StoredCredentials:
|
|
539
|
+
if any(not isinstance(value[field], str) or not value[field] for field in _REQUIRED_FIELDS):
|
|
540
|
+
raise LoginError(
|
|
541
|
+
"LOGIN_CREDENTIALS_UNREADABLE",
|
|
542
|
+
f"the stored credential at {path} is not the file this command writes",
|
|
543
|
+
)
|
|
544
|
+
return StoredCredentials(**{field: value[field] for field in _REQUIRED_FIELDS})
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _install_manifest(path: Path, backend_name: str) -> None:
|
|
548
|
+
payload = canonical_json_bytes(
|
|
549
|
+
{"backend": backend_name, "schema_version": CREDENTIAL_MANIFEST_SCHEMA}
|
|
550
|
+
)
|
|
551
|
+
stage = path.parent / f".{path.name}.{secrets.token_hex(16)}.stage"
|
|
552
|
+
descriptor: int | None = None
|
|
553
|
+
directory_fd: int | None = None
|
|
554
|
+
try:
|
|
555
|
+
descriptor = os.open(
|
|
556
|
+
stage,
|
|
557
|
+
os.O_WRONLY
|
|
558
|
+
| os.O_CREAT
|
|
559
|
+
| os.O_EXCL
|
|
560
|
+
| getattr(os, "O_BINARY", 0)
|
|
561
|
+
| getattr(os, "O_NOFOLLOW", 0),
|
|
562
|
+
0o600,
|
|
563
|
+
)
|
|
564
|
+
if os.name == "posix":
|
|
565
|
+
os.fchmod(descriptor, 0o600)
|
|
566
|
+
offset = 0
|
|
567
|
+
while offset < len(payload):
|
|
568
|
+
written = os.write(descriptor, payload[offset:])
|
|
569
|
+
if written <= 0:
|
|
570
|
+
raise OSError("short manifest write")
|
|
571
|
+
offset += written
|
|
572
|
+
os.fsync(descriptor)
|
|
573
|
+
os.close(descriptor)
|
|
574
|
+
descriptor = None
|
|
575
|
+
os.replace(stage, path)
|
|
576
|
+
if os.name == "posix":
|
|
577
|
+
directory_fd = os.open(
|
|
578
|
+
path.parent,
|
|
579
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
580
|
+
)
|
|
581
|
+
os.fsync(directory_fd)
|
|
582
|
+
finally:
|
|
583
|
+
if descriptor is not None:
|
|
584
|
+
os.close(descriptor)
|
|
585
|
+
if directory_fd is not None:
|
|
586
|
+
os.close(directory_fd)
|
|
587
|
+
with contextlib.suppress(OSError):
|
|
588
|
+
stage.unlink()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _login_store_error(error: CredentialStoreError) -> LoginError:
|
|
592
|
+
suffix = error.code.removeprefix("CREDENTIAL_")
|
|
593
|
+
details = {
|
|
594
|
+
"CREDENTIAL_STORE_UNAVAILABLE": (
|
|
595
|
+
"native protected storage is unavailable; explicitly select "
|
|
596
|
+
"--credential-store secure-file to use an owner-controlled file"
|
|
597
|
+
),
|
|
598
|
+
"CREDENTIAL_STORE_WRITE_FAILED": "the protected credential could not be written",
|
|
599
|
+
"CREDENTIAL_STORE_READBACK_FAILED": (
|
|
600
|
+
"the protected credential could not be verified after writing"
|
|
601
|
+
),
|
|
602
|
+
"CREDENTIAL_STORE_PERMISSIONS": ("the selected credential store is not owner controlled"),
|
|
603
|
+
"CREDENTIAL_STORE_CORRUPT": "the protected credential is unreadable",
|
|
604
|
+
"CREDENTIAL_LOCKED": "another credential operation is already running",
|
|
605
|
+
"CREDENTIAL_LOCK_UNAVAILABLE": "the credential operation lock is unavailable",
|
|
606
|
+
}
|
|
607
|
+
return LoginError(
|
|
608
|
+
f"LOGIN_CREDENTIAL_{suffix}",
|
|
609
|
+
details.get(error.code, "the protected credential operation failed"),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
__all__ = [
|
|
614
|
+
"CLOUD_URL_ENV",
|
|
615
|
+
"CREDENTIALS_DIR_NAME",
|
|
616
|
+
"CREDENTIALS_FILENAME",
|
|
617
|
+
"DEFAULT_CLOUD_URL",
|
|
618
|
+
"LoginError",
|
|
619
|
+
"ResolvedCloudCredentials",
|
|
620
|
+
"StoredCredentials",
|
|
621
|
+
"credential_transaction_store",
|
|
622
|
+
"credentials_path",
|
|
623
|
+
"default_credentials_dir",
|
|
624
|
+
"install_credential_manifest",
|
|
625
|
+
"read_credentials",
|
|
626
|
+
"refuse_existing_credentials",
|
|
627
|
+
"remove_credential_manifest_under_held_lock",
|
|
628
|
+
"resolve_cloud_credentials",
|
|
629
|
+
"store_credentials",
|
|
630
|
+
"whoami_payload",
|
|
631
|
+
]
|