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,551 @@
|
|
|
1
|
+
"""Zero-third-party native protected-store adapters.
|
|
2
|
+
|
|
3
|
+
The adapter selected for an operating system is final. A missing, locked, timed-out, or refusing
|
|
4
|
+
native facility is a typed error; this module never catches one and substitutes secure-file.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from mostlyright.data_harness.ux.credential_store import (
|
|
15
|
+
MAX_CREDENTIAL_ENVELOPE_BYTES,
|
|
16
|
+
CredentialBackend,
|
|
17
|
+
CredentialStoreError,
|
|
18
|
+
SecureFileBackend,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
_SERVICE = "md.mostlyright.cli"
|
|
22
|
+
_ACCOUNT = "default"
|
|
23
|
+
_SECRET_TOOL_TIMEOUT_SECONDS = 8.0
|
|
24
|
+
_SECRET_TOOL_ATTRIBUTES = ("application", "mostlyright-cli", "profile", "default")
|
|
25
|
+
_DPAPI_FLAGS = 0x1 # CRYPTPROTECT_UI_FORBIDDEN; intentionally excludes LOCAL_MACHINE (0x4).
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KeychainFFI(Protocol):
|
|
29
|
+
def read(self, *, service: str, account: str) -> bytes | None: ...
|
|
30
|
+
|
|
31
|
+
def write(self, payload: bytearray, *, service: str, account: str) -> None: ...
|
|
32
|
+
|
|
33
|
+
def delete(self, *, service: str, account: str) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DPAPIFFI(Protocol):
|
|
37
|
+
def protect(self, plaintext: bytearray, *, flags: int) -> bytes: ...
|
|
38
|
+
|
|
39
|
+
def unprotect(self, ciphertext: bytes, *, flags: int) -> bytearray: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MacOSKeychainBackend:
|
|
43
|
+
"""One Keychain generic-password item through Security.framework item APIs."""
|
|
44
|
+
|
|
45
|
+
name = "keychain"
|
|
46
|
+
|
|
47
|
+
def __init__(self, *, ffi: KeychainFFI | None = None) -> None:
|
|
48
|
+
try:
|
|
49
|
+
self._ffi = ffi or _MacOSSecurityFramework()
|
|
50
|
+
except Exception:
|
|
51
|
+
raise _native_refusal("Keychain", "initialization") from None
|
|
52
|
+
|
|
53
|
+
def read(self) -> bytes | None:
|
|
54
|
+
try:
|
|
55
|
+
payload = self._ffi.read(service=_SERVICE, account=_ACCOUNT)
|
|
56
|
+
if payload is not None and len(payload) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
57
|
+
raise ValueError("oversized item")
|
|
58
|
+
return payload
|
|
59
|
+
except Exception:
|
|
60
|
+
raise _native_refusal("Keychain", "copy") from None
|
|
61
|
+
|
|
62
|
+
def write(self, payload: bytes) -> None:
|
|
63
|
+
if len(payload) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
64
|
+
raise CredentialStoreError(
|
|
65
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected credential envelope exceeds its limit"
|
|
66
|
+
)
|
|
67
|
+
mutable = bytearray(payload)
|
|
68
|
+
try:
|
|
69
|
+
self._ffi.write(mutable, service=_SERVICE, account=_ACCOUNT)
|
|
70
|
+
except Exception:
|
|
71
|
+
raise _native_refusal("Keychain", "store") from None
|
|
72
|
+
finally:
|
|
73
|
+
_zero(mutable)
|
|
74
|
+
|
|
75
|
+
def delete(self) -> None:
|
|
76
|
+
try:
|
|
77
|
+
self._ffi.delete(service=_SERVICE, account=_ACCOUNT)
|
|
78
|
+
except Exception:
|
|
79
|
+
raise _native_refusal("Keychain", "delete") from None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DPAPIBackend:
|
|
83
|
+
"""Current-user DPAPI protection with only the encrypted blob stored atomically."""
|
|
84
|
+
|
|
85
|
+
name = "dpapi"
|
|
86
|
+
|
|
87
|
+
def __init__(self, config_dir: Path, *, ffi: DPAPIFFI | None = None) -> None:
|
|
88
|
+
try:
|
|
89
|
+
self._ffi = ffi or _WindowsDPAPI()
|
|
90
|
+
# This file contains only DPAPI ciphertext. Reusing the atomic private-file writer
|
|
91
|
+
# keeps replace/fsync/link checks identical without making secure-file a fallback.
|
|
92
|
+
self._blob = SecureFileBackend(
|
|
93
|
+
config_dir,
|
|
94
|
+
explicit=True,
|
|
95
|
+
filename="credentials.dpapi",
|
|
96
|
+
)
|
|
97
|
+
except Exception:
|
|
98
|
+
raise _native_refusal("DPAPI", "initialization") from None
|
|
99
|
+
|
|
100
|
+
def read(self) -> bytes | None:
|
|
101
|
+
plaintext: bytearray | None = None
|
|
102
|
+
try:
|
|
103
|
+
ciphertext = self._blob.read()
|
|
104
|
+
if ciphertext is None:
|
|
105
|
+
return None
|
|
106
|
+
plaintext = self._ffi.unprotect(ciphertext, flags=_DPAPI_FLAGS)
|
|
107
|
+
if len(plaintext) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
108
|
+
raise ValueError("oversized plaintext")
|
|
109
|
+
return bytes(plaintext)
|
|
110
|
+
except Exception:
|
|
111
|
+
raise _native_refusal("DPAPI", "unprotect") from None
|
|
112
|
+
finally:
|
|
113
|
+
if plaintext is not None:
|
|
114
|
+
_zero(plaintext)
|
|
115
|
+
|
|
116
|
+
def write(self, payload: bytes) -> None:
|
|
117
|
+
if len(payload) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
118
|
+
raise CredentialStoreError(
|
|
119
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected credential envelope exceeds its limit"
|
|
120
|
+
)
|
|
121
|
+
plaintext = bytearray(payload)
|
|
122
|
+
try:
|
|
123
|
+
ciphertext = self._ffi.protect(plaintext, flags=_DPAPI_FLAGS)
|
|
124
|
+
if not ciphertext or len(ciphertext) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
125
|
+
raise ValueError("invalid DPAPI ciphertext")
|
|
126
|
+
self._blob.write(ciphertext)
|
|
127
|
+
except Exception:
|
|
128
|
+
raise _native_refusal("DPAPI", "protect") from None
|
|
129
|
+
finally:
|
|
130
|
+
_zero(plaintext)
|
|
131
|
+
|
|
132
|
+
def delete(self) -> None:
|
|
133
|
+
try:
|
|
134
|
+
self._blob.delete()
|
|
135
|
+
except Exception:
|
|
136
|
+
raise _native_refusal("DPAPI", "delete") from None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class _ProcessResult(Protocol):
|
|
140
|
+
returncode: int
|
|
141
|
+
stdout: bytes
|
|
142
|
+
stderr: bytes
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_ProcessRunner = Callable[..., _ProcessResult]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SecretServiceBackend:
|
|
149
|
+
"""Linux Secret Service through bounded ``secret-tool`` subprocesses."""
|
|
150
|
+
|
|
151
|
+
name = "secret-service"
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
*,
|
|
156
|
+
executable: str,
|
|
157
|
+
process_runner: _ProcessRunner | None = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
if not executable:
|
|
160
|
+
raise _native_refusal("Secret Service", "probe")
|
|
161
|
+
if process_runner is None:
|
|
162
|
+
import subprocess
|
|
163
|
+
|
|
164
|
+
process_runner = subprocess.run
|
|
165
|
+
self._executable = executable
|
|
166
|
+
self._run = process_runner
|
|
167
|
+
|
|
168
|
+
def _invoke(self, operation: str, *, payload: bytes | None) -> _ProcessResult:
|
|
169
|
+
import subprocess
|
|
170
|
+
|
|
171
|
+
if operation == "store":
|
|
172
|
+
args = [
|
|
173
|
+
self._executable,
|
|
174
|
+
"store",
|
|
175
|
+
"--label=Mostly Right CLI",
|
|
176
|
+
*_SECRET_TOOL_ATTRIBUTES,
|
|
177
|
+
]
|
|
178
|
+
else:
|
|
179
|
+
args = [self._executable, operation, *_SECRET_TOOL_ATTRIBUTES]
|
|
180
|
+
try:
|
|
181
|
+
completed = self._run(
|
|
182
|
+
args,
|
|
183
|
+
input=payload,
|
|
184
|
+
capture_output=True,
|
|
185
|
+
timeout=_SECRET_TOOL_TIMEOUT_SECONDS,
|
|
186
|
+
check=False,
|
|
187
|
+
)
|
|
188
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
189
|
+
raise _native_refusal("Secret Service", operation) from None
|
|
190
|
+
# Never retain or render subprocess diagnostics. They may contain the item value, D-Bus
|
|
191
|
+
# details, a user name, or a hostile canary emitted by an injected executable.
|
|
192
|
+
if len(completed.stdout or b"") > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
193
|
+
raise _native_refusal("Secret Service", operation)
|
|
194
|
+
if len(completed.stderr or b"") > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
195
|
+
raise _native_refusal("Secret Service", operation)
|
|
196
|
+
return completed
|
|
197
|
+
|
|
198
|
+
def read(self) -> bytes | None:
|
|
199
|
+
completed = self._invoke("lookup", payload=None)
|
|
200
|
+
if completed.returncode == 1 and not completed.stdout and not completed.stderr:
|
|
201
|
+
return None
|
|
202
|
+
if completed.returncode != 0:
|
|
203
|
+
raise _native_refusal("Secret Service", "lookup")
|
|
204
|
+
if not completed.stdout:
|
|
205
|
+
raise _native_refusal("Secret Service", "lookup")
|
|
206
|
+
# ``secret-tool lookup`` appends a newline to its printed secret. Canonical envelopes do
|
|
207
|
+
# not contain trailing whitespace, so remove exactly that transport newline and no other
|
|
208
|
+
# byte.
|
|
209
|
+
return completed.stdout[:-1] if completed.stdout.endswith(b"\n") else completed.stdout
|
|
210
|
+
|
|
211
|
+
def write(self, payload: bytes) -> None:
|
|
212
|
+
if len(payload) > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
213
|
+
raise CredentialStoreError(
|
|
214
|
+
"CREDENTIAL_STORE_CORRUPT", "the protected credential envelope exceeds its limit"
|
|
215
|
+
)
|
|
216
|
+
completed = self._invoke("store", payload=payload)
|
|
217
|
+
if completed.returncode != 0:
|
|
218
|
+
raise _native_refusal("Secret Service", "store")
|
|
219
|
+
|
|
220
|
+
def delete(self) -> None:
|
|
221
|
+
completed = self._invoke("clear", payload=None)
|
|
222
|
+
if completed.returncode == 1 and not completed.stdout and not completed.stderr:
|
|
223
|
+
return
|
|
224
|
+
if completed.returncode != 0:
|
|
225
|
+
raise _native_refusal("Secret Service", "clear")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def native_backend(
|
|
229
|
+
config_dir: Path,
|
|
230
|
+
*,
|
|
231
|
+
platform_name: str | None = None,
|
|
232
|
+
executable_finder: Callable[[str], str | None] | None = None,
|
|
233
|
+
) -> CredentialBackend:
|
|
234
|
+
"""Select the current OS protected backend, with no fallback on absence or failure."""
|
|
235
|
+
|
|
236
|
+
selected = sys.platform if platform_name is None else platform_name
|
|
237
|
+
try:
|
|
238
|
+
if selected == "darwin":
|
|
239
|
+
return MacOSKeychainBackend()
|
|
240
|
+
if selected == "win32":
|
|
241
|
+
return DPAPIBackend(config_dir)
|
|
242
|
+
if selected.startswith("linux"):
|
|
243
|
+
if executable_finder is None:
|
|
244
|
+
import shutil
|
|
245
|
+
|
|
246
|
+
executable_finder = shutil.which
|
|
247
|
+
executable = executable_finder("secret-tool")
|
|
248
|
+
if executable is None:
|
|
249
|
+
raise CredentialStoreError(
|
|
250
|
+
"CREDENTIAL_STORE_UNAVAILABLE",
|
|
251
|
+
"Secret Service probe failed; explicitly select "
|
|
252
|
+
"--credential-store secure-file to use an owner-controlled file",
|
|
253
|
+
)
|
|
254
|
+
return SecretServiceBackend(executable=executable)
|
|
255
|
+
except CredentialStoreError:
|
|
256
|
+
raise
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
raise CredentialStoreError(
|
|
260
|
+
"CREDENTIAL_STORE_UNAVAILABLE",
|
|
261
|
+
"native protected storage is unavailable; explicitly select "
|
|
262
|
+
"--credential-store secure-file to use an owner-controlled file",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _native_refusal(adapter: str, outcome: str) -> CredentialStoreError:
|
|
267
|
+
return CredentialStoreError(
|
|
268
|
+
"CREDENTIAL_STORE_UNAVAILABLE",
|
|
269
|
+
f"{adapter} {outcome} failed",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _zero(value: bytearray) -> None:
|
|
274
|
+
for index in range(len(value)):
|
|
275
|
+
value[index] = 0
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _WindowsDPAPI:
|
|
279
|
+
def __init__(self) -> None:
|
|
280
|
+
import ctypes
|
|
281
|
+
|
|
282
|
+
if sys.platform != "win32":
|
|
283
|
+
raise OSError("DPAPI is not available")
|
|
284
|
+
self._ctypes = ctypes
|
|
285
|
+
|
|
286
|
+
class DataBlob(ctypes.Structure):
|
|
287
|
+
_fields_ = [
|
|
288
|
+
("cbData", ctypes.c_uint32),
|
|
289
|
+
("pbData", ctypes.POINTER(ctypes.c_ubyte)),
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
self._blob_type = DataBlob
|
|
293
|
+
self._crypt32 = ctypes.WinDLL("crypt32", use_last_error=True)
|
|
294
|
+
self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
295
|
+
self._crypt32.CryptProtectData.argtypes = [
|
|
296
|
+
ctypes.POINTER(DataBlob),
|
|
297
|
+
ctypes.c_wchar_p,
|
|
298
|
+
ctypes.POINTER(DataBlob),
|
|
299
|
+
ctypes.c_void_p,
|
|
300
|
+
ctypes.c_void_p,
|
|
301
|
+
ctypes.c_uint32,
|
|
302
|
+
ctypes.POINTER(DataBlob),
|
|
303
|
+
]
|
|
304
|
+
self._crypt32.CryptProtectData.restype = ctypes.c_int
|
|
305
|
+
self._crypt32.CryptUnprotectData.argtypes = [
|
|
306
|
+
ctypes.POINTER(DataBlob),
|
|
307
|
+
ctypes.c_void_p,
|
|
308
|
+
ctypes.POINTER(DataBlob),
|
|
309
|
+
ctypes.c_void_p,
|
|
310
|
+
ctypes.c_void_p,
|
|
311
|
+
ctypes.c_uint32,
|
|
312
|
+
ctypes.POINTER(DataBlob),
|
|
313
|
+
]
|
|
314
|
+
self._crypt32.CryptUnprotectData.restype = ctypes.c_int
|
|
315
|
+
self._kernel32.LocalFree.argtypes = [ctypes.c_void_p]
|
|
316
|
+
self._kernel32.LocalFree.restype = ctypes.c_void_p
|
|
317
|
+
|
|
318
|
+
def _input_blob(self, value: bytearray | bytes) -> tuple[object, object]:
|
|
319
|
+
ctypes = self._ctypes
|
|
320
|
+
if isinstance(value, bytearray):
|
|
321
|
+
buffer = (ctypes.c_ubyte * len(value)).from_buffer(value)
|
|
322
|
+
else:
|
|
323
|
+
buffer = (ctypes.c_ubyte * len(value)).from_buffer_copy(value)
|
|
324
|
+
return (
|
|
325
|
+
self._blob_type(len(value), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))),
|
|
326
|
+
buffer,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
def protect(self, plaintext: bytearray, *, flags: int) -> bytes:
|
|
330
|
+
ctypes = self._ctypes
|
|
331
|
+
source, keepalive = self._input_blob(plaintext)
|
|
332
|
+
result = self._blob_type()
|
|
333
|
+
if not self._crypt32.CryptProtectData(
|
|
334
|
+
ctypes.byref(source), None, None, None, None, flags, ctypes.byref(result)
|
|
335
|
+
):
|
|
336
|
+
raise OSError("CryptProtectData failed")
|
|
337
|
+
try:
|
|
338
|
+
return ctypes.string_at(result.pbData, result.cbData)
|
|
339
|
+
finally:
|
|
340
|
+
del keepalive
|
|
341
|
+
self._kernel32.LocalFree(result.pbData)
|
|
342
|
+
|
|
343
|
+
def unprotect(self, ciphertext: bytes, *, flags: int) -> bytearray:
|
|
344
|
+
ctypes = self._ctypes
|
|
345
|
+
source, keepalive = self._input_blob(ciphertext)
|
|
346
|
+
result = self._blob_type()
|
|
347
|
+
if not self._crypt32.CryptUnprotectData(
|
|
348
|
+
ctypes.byref(source), None, None, None, None, flags, ctypes.byref(result)
|
|
349
|
+
):
|
|
350
|
+
raise OSError("CryptUnprotectData failed")
|
|
351
|
+
try:
|
|
352
|
+
plaintext = bytearray(ctypes.string_at(result.pbData, result.cbData))
|
|
353
|
+
ctypes.memset(result.pbData, 0, result.cbData)
|
|
354
|
+
return plaintext
|
|
355
|
+
finally:
|
|
356
|
+
del keepalive
|
|
357
|
+
self._kernel32.LocalFree(result.pbData)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class _MacOSSecurityFramework:
|
|
361
|
+
"""Small CoreFoundation bridge for four Security.framework item operations."""
|
|
362
|
+
|
|
363
|
+
_SUCCESS = 0
|
|
364
|
+
_DUPLICATE = -25299
|
|
365
|
+
_NOT_FOUND = -25300
|
|
366
|
+
_UTF8 = 0x08000100
|
|
367
|
+
|
|
368
|
+
def __init__(self) -> None:
|
|
369
|
+
import ctypes
|
|
370
|
+
import ctypes.util
|
|
371
|
+
|
|
372
|
+
if sys.platform != "darwin":
|
|
373
|
+
raise OSError("Security.framework is not available")
|
|
374
|
+
self._ctypes = ctypes
|
|
375
|
+
security_path = ctypes.util.find_library("Security")
|
|
376
|
+
core_path = ctypes.util.find_library("CoreFoundation")
|
|
377
|
+
if not security_path or not core_path:
|
|
378
|
+
raise OSError("framework unavailable")
|
|
379
|
+
self._security = ctypes.CDLL(security_path)
|
|
380
|
+
self._core = ctypes.CDLL(core_path)
|
|
381
|
+
self._configure()
|
|
382
|
+
self._class = self._constant(self._security, "kSecClass")
|
|
383
|
+
self._generic = self._constant(self._security, "kSecClassGenericPassword")
|
|
384
|
+
self._service = self._constant(self._security, "kSecAttrService")
|
|
385
|
+
self._account = self._constant(self._security, "kSecAttrAccount")
|
|
386
|
+
self._value = self._constant(self._security, "kSecValueData")
|
|
387
|
+
self._return_data = self._constant(self._security, "kSecReturnData")
|
|
388
|
+
self._match_limit = self._constant(self._security, "kSecMatchLimit")
|
|
389
|
+
self._match_one = self._constant(self._security, "kSecMatchLimitOne")
|
|
390
|
+
self._true = self._constant(self._core, "kCFBooleanTrue")
|
|
391
|
+
|
|
392
|
+
def _constant(self, library: object, name: str) -> int:
|
|
393
|
+
ctypes = self._ctypes
|
|
394
|
+
value = ctypes.c_void_p.in_dll(library, name).value
|
|
395
|
+
if value is None:
|
|
396
|
+
raise OSError("framework constant unavailable")
|
|
397
|
+
return value
|
|
398
|
+
|
|
399
|
+
def _configure(self) -> None:
|
|
400
|
+
ctypes = self._ctypes
|
|
401
|
+
self._core.CFStringCreateWithBytes.argtypes = [
|
|
402
|
+
ctypes.c_void_p,
|
|
403
|
+
ctypes.c_void_p,
|
|
404
|
+
ctypes.c_long,
|
|
405
|
+
ctypes.c_uint32,
|
|
406
|
+
ctypes.c_bool,
|
|
407
|
+
]
|
|
408
|
+
self._core.CFStringCreateWithBytes.restype = ctypes.c_void_p
|
|
409
|
+
self._core.CFDataCreate.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long]
|
|
410
|
+
self._core.CFDataCreate.restype = ctypes.c_void_p
|
|
411
|
+
self._core.CFDataGetLength.argtypes = [ctypes.c_void_p]
|
|
412
|
+
self._core.CFDataGetLength.restype = ctypes.c_long
|
|
413
|
+
self._core.CFDataGetBytePtr.argtypes = [ctypes.c_void_p]
|
|
414
|
+
self._core.CFDataGetBytePtr.restype = ctypes.POINTER(ctypes.c_ubyte)
|
|
415
|
+
self._core.CFDictionaryCreate.argtypes = [
|
|
416
|
+
ctypes.c_void_p,
|
|
417
|
+
ctypes.c_void_p,
|
|
418
|
+
ctypes.c_void_p,
|
|
419
|
+
ctypes.c_long,
|
|
420
|
+
ctypes.c_void_p,
|
|
421
|
+
ctypes.c_void_p,
|
|
422
|
+
]
|
|
423
|
+
self._core.CFDictionaryCreate.restype = ctypes.c_void_p
|
|
424
|
+
self._core.CFRelease.argtypes = [ctypes.c_void_p]
|
|
425
|
+
self._security.SecItemCopyMatching.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
|
426
|
+
self._security.SecItemCopyMatching.restype = ctypes.c_int32
|
|
427
|
+
self._security.SecItemAdd.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
|
428
|
+
self._security.SecItemAdd.restype = ctypes.c_int32
|
|
429
|
+
self._security.SecItemUpdate.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
|
|
430
|
+
self._security.SecItemUpdate.restype = ctypes.c_int32
|
|
431
|
+
self._security.SecItemDelete.argtypes = [ctypes.c_void_p]
|
|
432
|
+
self._security.SecItemDelete.restype = ctypes.c_int32
|
|
433
|
+
|
|
434
|
+
def _string(self, value: str) -> int:
|
|
435
|
+
ctypes = self._ctypes
|
|
436
|
+
raw = value.encode("utf-8")
|
|
437
|
+
buffer = ctypes.create_string_buffer(raw)
|
|
438
|
+
result = self._core.CFStringCreateWithBytes(None, buffer, len(raw), self._UTF8, False)
|
|
439
|
+
if not result:
|
|
440
|
+
raise OSError("CFString allocation failed")
|
|
441
|
+
return result
|
|
442
|
+
|
|
443
|
+
def _data(self, value: bytearray) -> int:
|
|
444
|
+
ctypes = self._ctypes
|
|
445
|
+
buffer = (ctypes.c_ubyte * len(value)).from_buffer(value)
|
|
446
|
+
result = self._core.CFDataCreate(None, buffer, len(value))
|
|
447
|
+
if not result:
|
|
448
|
+
raise OSError("CFData allocation failed")
|
|
449
|
+
return result
|
|
450
|
+
|
|
451
|
+
def _dictionary(self, items: list[tuple[int, int]]) -> int:
|
|
452
|
+
ctypes = self._ctypes
|
|
453
|
+
keys = (ctypes.c_void_p * len(items))(*(key for key, _ in items))
|
|
454
|
+
values = (ctypes.c_void_p * len(items))(*(value for _, value in items))
|
|
455
|
+
result = self._core.CFDictionaryCreate(None, keys, values, len(items), None, None)
|
|
456
|
+
if not result:
|
|
457
|
+
raise OSError("CFDictionary allocation failed")
|
|
458
|
+
return result
|
|
459
|
+
|
|
460
|
+
def _query(self, service: str, account: str) -> tuple[int, list[int]]:
|
|
461
|
+
service_ref = self._string(service)
|
|
462
|
+
account_ref = self._string(account)
|
|
463
|
+
query = self._dictionary(
|
|
464
|
+
[
|
|
465
|
+
(self._class, self._generic),
|
|
466
|
+
(self._service, service_ref),
|
|
467
|
+
(self._account, account_ref),
|
|
468
|
+
]
|
|
469
|
+
)
|
|
470
|
+
return query, [service_ref, account_ref]
|
|
471
|
+
|
|
472
|
+
def _release_all(self, values: list[int]) -> None:
|
|
473
|
+
for value in reversed(values):
|
|
474
|
+
self._core.CFRelease(value)
|
|
475
|
+
|
|
476
|
+
def read(self, *, service: str, account: str) -> bytes | None:
|
|
477
|
+
ctypes = self._ctypes
|
|
478
|
+
query, owned = self._query(service, account)
|
|
479
|
+
rich_query = self._dictionary(
|
|
480
|
+
[
|
|
481
|
+
(self._class, self._generic),
|
|
482
|
+
(self._service, owned[0]),
|
|
483
|
+
(self._account, owned[1]),
|
|
484
|
+
(self._return_data, self._true),
|
|
485
|
+
(self._match_limit, self._match_one),
|
|
486
|
+
]
|
|
487
|
+
)
|
|
488
|
+
self._core.CFRelease(query)
|
|
489
|
+
result = ctypes.c_void_p()
|
|
490
|
+
try:
|
|
491
|
+
status = self._security.SecItemCopyMatching(rich_query, ctypes.byref(result))
|
|
492
|
+
if status == self._NOT_FOUND:
|
|
493
|
+
return None
|
|
494
|
+
if status != self._SUCCESS or not result.value:
|
|
495
|
+
raise OSError("SecItemCopyMatching failed")
|
|
496
|
+
length = self._core.CFDataGetLength(result)
|
|
497
|
+
if length < 0 or length > MAX_CREDENTIAL_ENVELOPE_BYTES:
|
|
498
|
+
raise OSError("invalid Keychain item size")
|
|
499
|
+
return ctypes.string_at(self._core.CFDataGetBytePtr(result), length)
|
|
500
|
+
finally:
|
|
501
|
+
if result.value:
|
|
502
|
+
self._core.CFRelease(result)
|
|
503
|
+
self._core.CFRelease(rich_query)
|
|
504
|
+
self._release_all(owned)
|
|
505
|
+
|
|
506
|
+
def write(self, payload: bytearray, *, service: str, account: str) -> None:
|
|
507
|
+
query, owned = self._query(service, account)
|
|
508
|
+
data = self._data(payload)
|
|
509
|
+
attributes = self._dictionary([(self._value, data)])
|
|
510
|
+
try:
|
|
511
|
+
status = self._security.SecItemUpdate(query, attributes)
|
|
512
|
+
if status == self._NOT_FOUND:
|
|
513
|
+
add = self._dictionary(
|
|
514
|
+
[
|
|
515
|
+
(self._class, self._generic),
|
|
516
|
+
(self._service, owned[0]),
|
|
517
|
+
(self._account, owned[1]),
|
|
518
|
+
(self._value, data),
|
|
519
|
+
]
|
|
520
|
+
)
|
|
521
|
+
try:
|
|
522
|
+
status = self._security.SecItemAdd(add, None)
|
|
523
|
+
finally:
|
|
524
|
+
self._core.CFRelease(add)
|
|
525
|
+
if status == self._DUPLICATE:
|
|
526
|
+
status = self._security.SecItemUpdate(query, attributes)
|
|
527
|
+
if status != self._SUCCESS:
|
|
528
|
+
raise OSError("Keychain write failed")
|
|
529
|
+
finally:
|
|
530
|
+
self._core.CFRelease(attributes)
|
|
531
|
+
self._core.CFRelease(data)
|
|
532
|
+
self._core.CFRelease(query)
|
|
533
|
+
self._release_all(owned)
|
|
534
|
+
|
|
535
|
+
def delete(self, *, service: str, account: str) -> None:
|
|
536
|
+
query, owned = self._query(service, account)
|
|
537
|
+
try:
|
|
538
|
+
status = self._security.SecItemDelete(query)
|
|
539
|
+
if status not in {self._SUCCESS, self._NOT_FOUND}:
|
|
540
|
+
raise OSError("SecItemDelete failed")
|
|
541
|
+
finally:
|
|
542
|
+
self._core.CFRelease(query)
|
|
543
|
+
self._release_all(owned)
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
__all__ = [
|
|
547
|
+
"DPAPIBackend",
|
|
548
|
+
"MacOSKeychainBackend",
|
|
549
|
+
"SecretServiceBackend",
|
|
550
|
+
"native_backend",
|
|
551
|
+
]
|