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,2533 @@
|
|
|
1
|
+
"""Signed, exact-candidate local data-agent review envelopes.
|
|
2
|
+
|
|
3
|
+
One review envelope is the directory ``<run_dir>/review``. It holds
|
|
4
|
+
assignment.json, backlog.json, decision.json, findings.json, and
|
|
5
|
+
reports/<lens>.json for both required lenses. Every member is a sealed mode-0444
|
|
6
|
+
file under a sealed mode-0555 root.
|
|
7
|
+
|
|
8
|
+
Finalization is one transaction and runs in this order: bind every absolute
|
|
9
|
+
run-directory ancestor by descriptor, take the exclusive publication lock,
|
|
10
|
+
verify the candidate, stage the envelope in a private temporary directory,
|
|
11
|
+
install it with an atomic rename that refuses to replace an existing envelope,
|
|
12
|
+
then re-verify the installed bytes while the staging descriptors and snapshot
|
|
13
|
+
leases are still held. Each step revalidates the bound chain and the lock, so a
|
|
14
|
+
same-UID actor cannot substitute a pathname midway through.
|
|
15
|
+
|
|
16
|
+
Entry points, in call order: ``_sign_enrolled_report`` signs one reviewer
|
|
17
|
+
report, ``_finalize_enrolled_review`` writes the verifier-signed decision, and
|
|
18
|
+
``verify_enrolled_review_decision`` re-verifies an installed envelope. Each
|
|
19
|
+
resolves its keys through an inherited coordinator session. The unenrolled forms
|
|
20
|
+
take their trust directly instead: ``_finalize_review`` takes the trusted
|
|
21
|
+
assignment and the verifier signer, and ``_verify_review_decision`` takes the
|
|
22
|
+
trusted verifier public key and, optionally, the trusted assignment.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import ctypes
|
|
29
|
+
import errno
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import select
|
|
33
|
+
import stat
|
|
34
|
+
import struct
|
|
35
|
+
import sys
|
|
36
|
+
import uuid
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path, PurePosixPath
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
import fcntl
|
|
43
|
+
except ImportError: # pragma: no cover - unavailable on the already-unsupported Windows path
|
|
44
|
+
fcntl = None # type: ignore[assignment]
|
|
45
|
+
|
|
46
|
+
from cryptography.exceptions import InvalidSignature
|
|
47
|
+
from cryptography.hazmat.primitives import serialization
|
|
48
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
49
|
+
|
|
50
|
+
from mostlyright.data_harness.canonical import sha256_bytes
|
|
51
|
+
from mostlyright.data_harness.pipeline import (
|
|
52
|
+
canonical_json_line_bytes,
|
|
53
|
+
verify_candidate,
|
|
54
|
+
)
|
|
55
|
+
from mostlyright.data_harness.signing import (
|
|
56
|
+
SignerProviderError,
|
|
57
|
+
TrustedReviewAuthority,
|
|
58
|
+
TrustedReviewAuthorityProvider,
|
|
59
|
+
TrustedSigner,
|
|
60
|
+
resolve_review_authority,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
REPORT_SCHEMA_VERSION = "agent-review.v2"
|
|
64
|
+
ASSIGNMENT_SCHEMA_VERSION = "review-assignment.v1"
|
|
65
|
+
DECISION_SCHEMA_VERSION = "review-decision.v2"
|
|
66
|
+
VERIFIER_VERSION = "local-review-verifier.v2"
|
|
67
|
+
REQUIRED_LENSES = frozenset({"question_source_fit", "transformation_data_quality"})
|
|
68
|
+
_SEVERITIES = frozenset({"blocker", "critical", "high", "medium", "low"})
|
|
69
|
+
_BLOCKING_SEVERITIES = frozenset({"blocker", "critical", "high"})
|
|
70
|
+
_REVIEW_ROOT_MEMBERS = frozenset(
|
|
71
|
+
{"assignment.json", "backlog.json", "decision.json", "findings.json", "reports"}
|
|
72
|
+
)
|
|
73
|
+
_REVIEW_REPORT_MEMBERS = frozenset(f"{lens}.json" for lens in REQUIRED_LENSES)
|
|
74
|
+
_REVIEW_FILE_PATHS = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"assignment.json",
|
|
77
|
+
"backlog.json",
|
|
78
|
+
"decision.json",
|
|
79
|
+
"findings.json",
|
|
80
|
+
*(f"reports/{name}" for name in _REVIEW_REPORT_MEMBERS),
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
_REVIEW_PUBLICATION_LOCK = ".review.lock"
|
|
84
|
+
_MAX_REVIEW_MEMBER_BYTES = 10 * 1024 * 1024
|
|
85
|
+
# Linux inotify event bits, spelled here because ctypes gives no header constants. Values
|
|
86
|
+
# are from <sys/inotify.h> and are ABI-stable.
|
|
87
|
+
_IN_ATTRIB = 0x00000004
|
|
88
|
+
_IN_DELETE_SELF = 0x00000400
|
|
89
|
+
_IN_MOVE_SELF = 0x00000800
|
|
90
|
+
_IN_UNMOUNT = 0x00002000
|
|
91
|
+
# The mask retained review ancestors are watched under: any attribute change, self-delete,
|
|
92
|
+
# self-move, or unmount. IN_IGNORED and IN_Q_OVERFLOW arrive whatever the mask is set to,
|
|
93
|
+
# so any readable event at all fails the transaction closed.
|
|
94
|
+
_REVIEW_ANCESTOR_WATCH_MASK = _IN_ATTRIB | _IN_DELETE_SELF | _IN_MOVE_SELF | _IN_UNMOUNT
|
|
95
|
+
_SECURE_REVIEW_STAGING_SUPPORTED = (
|
|
96
|
+
os.name != "nt"
|
|
97
|
+
and fcntl is not None
|
|
98
|
+
and bool(getattr(os, "O_DIRECTORY", 0))
|
|
99
|
+
and bool(getattr(os, "O_NOFOLLOW", 0))
|
|
100
|
+
and hasattr(os, "fchmod")
|
|
101
|
+
and os.open in os.supports_dir_fd
|
|
102
|
+
and os.stat in os.supports_dir_fd
|
|
103
|
+
and os.mkdir in os.supports_dir_fd
|
|
104
|
+
and os.stat in os.supports_follow_symlinks
|
|
105
|
+
and os.listdir in os.supports_fd
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class ReviewError(RuntimeError):
|
|
110
|
+
"""A review envelope failed authentication or policy validation.
|
|
111
|
+
|
|
112
|
+
``code`` carries no authority: nothing in the review path reads it, and it exists only so the
|
|
113
|
+
command line can look up the plain sentences that go with a refusal a person can actually hit.
|
|
114
|
+
It is what a person quotes and what an agent branches on, so every raise site in this module
|
|
115
|
+
passes one and ``tests/_typed_errors.py`` fails when one does not. It stays optional in the
|
|
116
|
+
signature because an exception is rebuilt from its message alone when it crosses a process
|
|
117
|
+
boundary; what is enforced is the raise site, which is where the code is decided.
|
|
118
|
+
|
|
119
|
+
The codes are grouped by the document the refusal is about -- ``ASSIGNMENT_``, ``REPORT_``,
|
|
120
|
+
``DECISION_``, ``ENVELOPE_``, ``LOCK_``, ``KEY_`` -- and the path-safety refusals reuse the
|
|
121
|
+
``CANDIDATE_ANCESTOR_`` codes the rest of the tree already raises for the same class of
|
|
122
|
+
failure, so one wording answers for both rather than a second one being invented here.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
def __init__(self, message: str, *, code: str | None = None) -> None:
|
|
126
|
+
super().__init__(message)
|
|
127
|
+
self.code = code
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _secure_review_staging_supported() -> bool:
|
|
131
|
+
"""Return whether exact descriptor-relative staging primitives are available."""
|
|
132
|
+
|
|
133
|
+
return _SECURE_REVIEW_STAGING_SUPPORTED
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _secure_review_verification_supported() -> bool:
|
|
137
|
+
"""Return whether review verification can retain an exact directory snapshot."""
|
|
138
|
+
|
|
139
|
+
return _SECURE_REVIEW_STAGING_SUPPORTED
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# inotify directory watches also receive named (len > 0) events for entries INSIDE the
|
|
143
|
+
# watched directory; only unnamed (len == 0) events — IN_ATTRIB/IN_DELETE_SELF/
|
|
144
|
+
# IN_MOVE_SELF/IN_UNMOUNT/IN_IGNORED and queue overflow — describe the watched inode
|
|
145
|
+
# itself, which is the kqueue EVFILT_VNODE semantic this monitor is defined against.
|
|
146
|
+
_INOTIFY_EVENT_HEADER = struct.Struct("iIII")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class _ReviewAncestorMonitor:
|
|
151
|
+
"""Retain kernel move/delete notifications for every bound run ancestor."""
|
|
152
|
+
|
|
153
|
+
kind: str
|
|
154
|
+
resource: Any
|
|
155
|
+
event_capacity: int
|
|
156
|
+
|
|
157
|
+
def poll(self) -> bool:
|
|
158
|
+
try:
|
|
159
|
+
if self.kind == "kqueue":
|
|
160
|
+
return bool(self.resource.control(None, self.event_capacity, 0))
|
|
161
|
+
while True:
|
|
162
|
+
try:
|
|
163
|
+
payload = os.read(self.resource, 4096)
|
|
164
|
+
except BlockingIOError:
|
|
165
|
+
return False
|
|
166
|
+
offset = 0
|
|
167
|
+
while offset + _INOTIFY_EVENT_HEADER.size <= len(payload):
|
|
168
|
+
_wd, _mask, _cookie, name_length = _INOTIFY_EVENT_HEADER.unpack_from(
|
|
169
|
+
payload, offset
|
|
170
|
+
)
|
|
171
|
+
if name_length == 0:
|
|
172
|
+
return True
|
|
173
|
+
offset += _INOTIFY_EVENT_HEADER.size + name_length
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
raise ReviewError(
|
|
176
|
+
"review run ancestor monitor became unavailable", code="CANDIDATE_ANCESTOR_RACE"
|
|
177
|
+
) from exc
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
if self.kind == "kqueue":
|
|
181
|
+
self.resource.close()
|
|
182
|
+
else:
|
|
183
|
+
os.close(self.resource)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# One retained ancestor of the run directory: descriptor, parent_fd, name, entry identity
|
|
187
|
+
# (st_dev, st_ino, st_nlink), and a human label used in refusal messages. The
|
|
188
|
+
# filesystem-anchor record carries no parent_fd and no name, so revalidation checks it by
|
|
189
|
+
# descriptor only. pipeline.py retains the same shape on its own run handle; the two must
|
|
190
|
+
# change together.
|
|
191
|
+
_RetainedAncestorRecord = tuple[int, int | None, str | None, tuple[int, int, int], str]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@dataclass
|
|
195
|
+
class _ReviewRunHandle:
|
|
196
|
+
"""One retained absolute run-directory chain for a complete review transaction."""
|
|
197
|
+
|
|
198
|
+
run_dir: Path
|
|
199
|
+
descriptors: list[int]
|
|
200
|
+
records: list[_RetainedAncestorRecord]
|
|
201
|
+
monitor: _ReviewAncestorMonitor
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def run_fd(self) -> int:
|
|
205
|
+
return self.descriptors[-1]
|
|
206
|
+
|
|
207
|
+
def close(self) -> None:
|
|
208
|
+
self.monitor.close()
|
|
209
|
+
for descriptor in reversed(self.descriptors):
|
|
210
|
+
os.close(descriptor)
|
|
211
|
+
self.descriptors.clear()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class _ReviewPublicationLock:
|
|
216
|
+
"""One descriptor-bound exclusive publication lock retained through verification."""
|
|
217
|
+
|
|
218
|
+
handle: _ReviewRunHandle
|
|
219
|
+
descriptor: int
|
|
220
|
+
lock_stat: os.stat_result
|
|
221
|
+
|
|
222
|
+
def validate(self) -> None:
|
|
223
|
+
_validate_review_run_handle(self.handle)
|
|
224
|
+
opened = os.fstat(self.descriptor)
|
|
225
|
+
_require_stable_stat(self.lock_stat, opened, "review publication lock")
|
|
226
|
+
_require_review_publication_lock(opened)
|
|
227
|
+
named = os.stat(
|
|
228
|
+
_REVIEW_PUBLICATION_LOCK,
|
|
229
|
+
dir_fd=self.handle.run_fd,
|
|
230
|
+
follow_symlinks=False,
|
|
231
|
+
)
|
|
232
|
+
_require_stable_stat(self.lock_stat, named, "review publication lock")
|
|
233
|
+
_require_review_publication_lock(named)
|
|
234
|
+
|
|
235
|
+
def close(self) -> None:
|
|
236
|
+
if fcntl is not None:
|
|
237
|
+
fcntl.flock(self.descriptor, fcntl.LOCK_UN)
|
|
238
|
+
os.close(self.descriptor)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class _ReviewSnapshotLease:
|
|
243
|
+
"""Retain the exact review child root through the result linearization point."""
|
|
244
|
+
|
|
245
|
+
handle: _ReviewRunHandle
|
|
246
|
+
review_fd: int
|
|
247
|
+
review_stat: os.stat_result
|
|
248
|
+
owns_review_fd: bool
|
|
249
|
+
|
|
250
|
+
def validate(self) -> None:
|
|
251
|
+
_validate_review_run_handle(self.handle)
|
|
252
|
+
_require_stable_stat(
|
|
253
|
+
self.review_stat,
|
|
254
|
+
os.fstat(self.review_fd),
|
|
255
|
+
"review root",
|
|
256
|
+
)
|
|
257
|
+
named = os.stat(
|
|
258
|
+
"review",
|
|
259
|
+
dir_fd=self.handle.run_fd,
|
|
260
|
+
follow_symlinks=False,
|
|
261
|
+
)
|
|
262
|
+
_require_stable_stat(self.review_stat, named, "review root")
|
|
263
|
+
|
|
264
|
+
def close(self) -> None:
|
|
265
|
+
if self.owns_review_fd:
|
|
266
|
+
os.close(self.review_fd)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass(frozen=True)
|
|
270
|
+
class ReviewResult:
|
|
271
|
+
"""Authenticated outcome of one review envelope.
|
|
272
|
+
|
|
273
|
+
status is exactly one of two values. It is "fixes_required" when any finding
|
|
274
|
+
has severity blocker, critical, or high, and "reviewed_candidate" otherwise.
|
|
275
|
+
candidate_digest names the exact reviewed candidate. decision_digest is the
|
|
276
|
+
SHA-256 of the canonical decision payload; the verifier signs that payload
|
|
277
|
+
with this digest appended. review_dir is the installed envelope directory.
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
status: str
|
|
281
|
+
candidate_digest: str
|
|
282
|
+
decision_digest: str
|
|
283
|
+
review_dir: Path
|
|
284
|
+
decision_document: dict[str, Any]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def public_key_to_base64(public_key: Ed25519PublicKey) -> str:
|
|
288
|
+
raw = public_key.public_bytes(
|
|
289
|
+
serialization.Encoding.Raw,
|
|
290
|
+
serialization.PublicFormat.Raw,
|
|
291
|
+
)
|
|
292
|
+
return base64.b64encode(raw).decode("ascii")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def load_public_key_base64(value: str) -> Ed25519PublicKey:
|
|
296
|
+
try:
|
|
297
|
+
raw = base64.b64decode(value, validate=True)
|
|
298
|
+
return Ed25519PublicKey.from_public_bytes(raw)
|
|
299
|
+
except (ValueError, TypeError) as exc:
|
|
300
|
+
raise ReviewError("invalid Ed25519 public key", code="KEY_INVALID") from exc
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _trusted_authority_signer(value: Any, role: str) -> TrustedSigner:
|
|
304
|
+
if not callable(getattr(value, "sign", None)) or not callable(
|
|
305
|
+
getattr(value, "public_key", None)
|
|
306
|
+
):
|
|
307
|
+
raise SignerProviderError(f"trusted review authority returned an invalid {role} signer")
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _review_authority_material(
|
|
312
|
+
provider: TrustedReviewAuthorityProvider | None,
|
|
313
|
+
) -> tuple[TrustedReviewAuthority, Any, TrustedSigner]:
|
|
314
|
+
authority = resolve_review_authority(provider)
|
|
315
|
+
try:
|
|
316
|
+
assignment = authority.assignment_value()
|
|
317
|
+
verifier = _trusted_authority_signer(authority.verifier_signer(), "verifier")
|
|
318
|
+
except (ReviewError, SignerProviderError):
|
|
319
|
+
raise
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
raise SignerProviderError(
|
|
322
|
+
"trusted review authority could not provide its enrollment"
|
|
323
|
+
) from exc
|
|
324
|
+
return authority, assignment, verifier
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _sign_report(payload: Any, private_key: TrustedSigner) -> dict[str, Any]:
|
|
328
|
+
"""Internal primitive: validate and sign one already-authorized report payload."""
|
|
329
|
+
|
|
330
|
+
normalized = _parse_report_payload(payload)
|
|
331
|
+
signature = private_key.sign(canonical_json_line_bytes(normalized))
|
|
332
|
+
return {**normalized, "signature": base64.b64encode(signature).decode("ascii")}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _sign_assigned_report(
|
|
336
|
+
payload: Any,
|
|
337
|
+
*,
|
|
338
|
+
trusted_assignment_value: Any,
|
|
339
|
+
reviewer_signer: TrustedSigner,
|
|
340
|
+
) -> dict[str, Any]:
|
|
341
|
+
"""Sign only when a trusted enrollment authorizes this exact report identity."""
|
|
342
|
+
|
|
343
|
+
assignment = _parse_assignment(trusted_assignment_value)
|
|
344
|
+
normalized = _parse_report_payload(payload)
|
|
345
|
+
reviewer = next(
|
|
346
|
+
(
|
|
347
|
+
item
|
|
348
|
+
for item in assignment["reviewers"]
|
|
349
|
+
if item["attempt_id"] == normalized["reviewer_attempt"]
|
|
350
|
+
),
|
|
351
|
+
None,
|
|
352
|
+
)
|
|
353
|
+
if reviewer is None:
|
|
354
|
+
raise ReviewError(
|
|
355
|
+
"report reviewer attempt is not enrolled by the trusted authority",
|
|
356
|
+
code="ASSIGNMENT_ATTEMPT_NOT_ENROLLED",
|
|
357
|
+
)
|
|
358
|
+
expected = {
|
|
359
|
+
"assignment_digest": _normalized_assignment_digest(assignment),
|
|
360
|
+
"candidate_digest": assignment["candidate_digest"],
|
|
361
|
+
"validation_policy_digest": assignment["validation_policy_digest"],
|
|
362
|
+
"cycle_lineage": assignment["cycle_lineage"],
|
|
363
|
+
"failed_cycle_count": assignment["failed_cycle_count"],
|
|
364
|
+
"reviewer_principal": reviewer["principal_id"],
|
|
365
|
+
"reviewer_attempt": reviewer["attempt_id"],
|
|
366
|
+
"reviewer_key_id": reviewer["key_id"],
|
|
367
|
+
"lens": reviewer["lens"],
|
|
368
|
+
}
|
|
369
|
+
for field, value in expected.items():
|
|
370
|
+
if normalized[field] != value:
|
|
371
|
+
raise ReviewError(
|
|
372
|
+
f"report {field} is not authorized by the trusted assignment",
|
|
373
|
+
code="ASSIGNMENT_FIELD_UNAUTHORIZED",
|
|
374
|
+
)
|
|
375
|
+
if public_key_to_base64(reviewer_signer.public_key()) != reviewer["public_key"]:
|
|
376
|
+
raise ReviewError(
|
|
377
|
+
"reviewer signer does not match the trusted assignment enrollment",
|
|
378
|
+
code="ASSIGNMENT_SIGNER_MISMATCH",
|
|
379
|
+
)
|
|
380
|
+
return _sign_report(normalized, reviewer_signer)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _sign_enrolled_report(
|
|
384
|
+
payload: Any,
|
|
385
|
+
*,
|
|
386
|
+
review_authority_provider: TrustedReviewAuthorityProvider | None,
|
|
387
|
+
) -> dict[str, Any]:
|
|
388
|
+
"""Sign one report only through an inherited coordinator session."""
|
|
389
|
+
|
|
390
|
+
authority = resolve_review_authority(review_authority_provider)
|
|
391
|
+
try:
|
|
392
|
+
assignment = authority.assignment_value()
|
|
393
|
+
except (ReviewError, SignerProviderError):
|
|
394
|
+
raise
|
|
395
|
+
except Exception as exc:
|
|
396
|
+
raise SignerProviderError(
|
|
397
|
+
"trusted review authority could not provide its enrollment"
|
|
398
|
+
) from exc
|
|
399
|
+
normalized = _parse_report_payload(payload)
|
|
400
|
+
try:
|
|
401
|
+
signer = _trusted_authority_signer(
|
|
402
|
+
authority.reviewer_signer_for(normalized["reviewer_attempt"]),
|
|
403
|
+
"reviewer",
|
|
404
|
+
)
|
|
405
|
+
except (ReviewError, SignerProviderError):
|
|
406
|
+
raise
|
|
407
|
+
except Exception as exc:
|
|
408
|
+
raise SignerProviderError(
|
|
409
|
+
"trusted review authority could not resolve the reviewer enrollment"
|
|
410
|
+
) from exc
|
|
411
|
+
return _sign_assigned_report(
|
|
412
|
+
normalized,
|
|
413
|
+
trusted_assignment_value=assignment,
|
|
414
|
+
reviewer_signer=signer,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _finalize_enrolled_review(
|
|
419
|
+
*,
|
|
420
|
+
run_dir: Path,
|
|
421
|
+
report_values: list[Any],
|
|
422
|
+
review_authority_provider: TrustedReviewAuthorityProvider | None,
|
|
423
|
+
) -> ReviewResult:
|
|
424
|
+
"""Finalize one review only through an inherited coordinator session."""
|
|
425
|
+
|
|
426
|
+
_authority, assignment, verifier = _review_authority_material(review_authority_provider)
|
|
427
|
+
return _finalize_review(
|
|
428
|
+
run_dir=run_dir,
|
|
429
|
+
assignment_value=assignment,
|
|
430
|
+
report_values=report_values,
|
|
431
|
+
verifier_private_key=verifier,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _finalize_review(
|
|
436
|
+
*,
|
|
437
|
+
run_dir: Path,
|
|
438
|
+
assignment_value: Any,
|
|
439
|
+
report_values: list[Any],
|
|
440
|
+
verifier_private_key: TrustedSigner,
|
|
441
|
+
_run_parent_fd: int | None = None,
|
|
442
|
+
_transaction_validator: Any | None = None,
|
|
443
|
+
) -> ReviewResult:
|
|
444
|
+
"""Authenticate two reports and write one immutable verifier-signed decision envelope."""
|
|
445
|
+
|
|
446
|
+
if not _secure_review_staging_supported():
|
|
447
|
+
raise ReviewError(
|
|
448
|
+
"secure descriptor-relative review staging is unavailable on this platform",
|
|
449
|
+
code="PLATFORM_REVIEW_STAGING_UNSUPPORTED",
|
|
450
|
+
)
|
|
451
|
+
handle = (
|
|
452
|
+
_open_review_run_handle(run_dir)
|
|
453
|
+
if _run_parent_fd is None
|
|
454
|
+
else _open_review_run_handle_from_parent(run_dir, _run_parent_fd)
|
|
455
|
+
)
|
|
456
|
+
publication_lock: _ReviewPublicationLock | None = None
|
|
457
|
+
staging_name = f".review.tmp-{uuid.uuid4().hex}"
|
|
458
|
+
staging_fd = -1
|
|
459
|
+
renamed = False
|
|
460
|
+
installed_identity_owned = False
|
|
461
|
+
installation_durability_confirmed = False
|
|
462
|
+
staging_identity: tuple[int, int, int] | None = None
|
|
463
|
+
verification_leases: list[Any] = []
|
|
464
|
+
try:
|
|
465
|
+
publication_lock = _acquire_review_publication_lock(handle)
|
|
466
|
+
|
|
467
|
+
def validate_transaction() -> None:
|
|
468
|
+
if publication_lock is None: # pragma: no cover - developer invariant
|
|
469
|
+
raise ReviewError("review publication lock is unavailable", code="LOCK_UNAVAILABLE")
|
|
470
|
+
if _transaction_validator is not None:
|
|
471
|
+
_transaction_validator()
|
|
472
|
+
publication_lock.validate()
|
|
473
|
+
|
|
474
|
+
verified_candidate = verify_candidate(
|
|
475
|
+
handle.run_dir,
|
|
476
|
+
_run_fd=handle.run_fd,
|
|
477
|
+
_ancestor_validator=validate_transaction,
|
|
478
|
+
)
|
|
479
|
+
validate_transaction()
|
|
480
|
+
assignment = _parse_assignment(assignment_value)
|
|
481
|
+
_validate_assignment_against_candidate(assignment, verified_candidate)
|
|
482
|
+
verifier_public = verifier_private_key.public_key()
|
|
483
|
+
if public_key_to_base64(verifier_public) != assignment["verifier"]["public_key"]:
|
|
484
|
+
raise ReviewError(
|
|
485
|
+
"verifier private key does not match the enrolled assignment key",
|
|
486
|
+
code="ASSIGNMENT_VERIFIER_KEY_MISMATCH",
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
reports = [_parse_signed_report(value) for value in report_values]
|
|
490
|
+
if len(reports) != 2:
|
|
491
|
+
raise ReviewError("exactly two signed review reports are required", code="REPORT_COUNT")
|
|
492
|
+
assignments_by_lens = {item["lens"]: item for item in assignment["reviewers"]}
|
|
493
|
+
reports_by_lens: dict[str, dict[str, Any]] = {}
|
|
494
|
+
for report in reports:
|
|
495
|
+
lens = report["lens"]
|
|
496
|
+
if lens in reports_by_lens:
|
|
497
|
+
raise ReviewError(f"duplicate report lens: {lens}", code="REPORT_LENS_DUPLICATE")
|
|
498
|
+
expected = assignments_by_lens.get(lens)
|
|
499
|
+
if expected is None:
|
|
500
|
+
raise ReviewError(
|
|
501
|
+
f"report lens is not assigned: {lens}", code="REPORT_LENS_UNASSIGNED"
|
|
502
|
+
)
|
|
503
|
+
_verify_report(report, expected, verified_candidate, assignment)
|
|
504
|
+
reports_by_lens[lens] = report
|
|
505
|
+
if set(reports_by_lens) != REQUIRED_LENSES:
|
|
506
|
+
raise ReviewError(
|
|
507
|
+
"both required review lenses must be present", code="REPORT_LENSES_INCOMPLETE"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
findings = _normalize_findings(reports_by_lens)
|
|
511
|
+
backlog = [finding for finding in findings if finding["severity"] in {"medium", "low"}]
|
|
512
|
+
status = _derive_review_status(findings, assignment["failed_cycle_count"])
|
|
513
|
+
expected_decision_digest = _expected_review_decision_digest(
|
|
514
|
+
assignment=assignment,
|
|
515
|
+
reports_by_lens=reports_by_lens,
|
|
516
|
+
findings=findings,
|
|
517
|
+
backlog=backlog,
|
|
518
|
+
status=status,
|
|
519
|
+
verified_candidate=verified_candidate,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
try:
|
|
523
|
+
os.stat("review", dir_fd=handle.run_fd, follow_symlinks=False)
|
|
524
|
+
except FileNotFoundError:
|
|
525
|
+
pass
|
|
526
|
+
else:
|
|
527
|
+
_ensure_installed_review_durable(handle)
|
|
528
|
+
verified = _verify_review_decision(
|
|
529
|
+
handle.run_dir,
|
|
530
|
+
trusted_verifier_public_key=verifier_public,
|
|
531
|
+
trusted_assignment_value=assignment,
|
|
532
|
+
_run_handle=handle,
|
|
533
|
+
_transaction_validator=validate_transaction,
|
|
534
|
+
)
|
|
535
|
+
if verified.decision_digest != expected_decision_digest or verified.status != status:
|
|
536
|
+
raise ReviewError(
|
|
537
|
+
"existing review envelope does not match this finalization request",
|
|
538
|
+
code="ENVELOPE_MISMATCH",
|
|
539
|
+
)
|
|
540
|
+
validate_transaction()
|
|
541
|
+
return verified
|
|
542
|
+
os.mkdir(staging_name, mode=0o700, dir_fd=handle.run_fd)
|
|
543
|
+
created_staging = os.stat(
|
|
544
|
+
staging_name,
|
|
545
|
+
dir_fd=handle.run_fd,
|
|
546
|
+
follow_symlinks=False,
|
|
547
|
+
)
|
|
548
|
+
if not stat.S_ISDIR(created_staging.st_mode):
|
|
549
|
+
raise ReviewError(
|
|
550
|
+
"review staging root is not a directory", code="ENVELOPE_STAGING_ROOT_INVALID"
|
|
551
|
+
)
|
|
552
|
+
staging_identity = _entry_identity(created_staging)
|
|
553
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
554
|
+
staging_fd = os.open(staging_name, directory_flags, dir_fd=handle.run_fd)
|
|
555
|
+
_require_same_identity(created_staging, os.fstat(staging_fd), "review staging root")
|
|
556
|
+
decision_digest = _stage_review_envelope(
|
|
557
|
+
staging_fd,
|
|
558
|
+
parent_fd=handle.run_fd,
|
|
559
|
+
staging_name=staging_name,
|
|
560
|
+
expected_root_identity=staging_identity,
|
|
561
|
+
assignment=assignment,
|
|
562
|
+
reports_by_lens=reports_by_lens,
|
|
563
|
+
findings=findings,
|
|
564
|
+
backlog=backlog,
|
|
565
|
+
status=status,
|
|
566
|
+
verified_candidate=verified_candidate,
|
|
567
|
+
verifier_private_key=verifier_private_key,
|
|
568
|
+
)
|
|
569
|
+
validate_transaction()
|
|
570
|
+
try:
|
|
571
|
+
_rename_no_replace(
|
|
572
|
+
handle.run_dir / staging_name,
|
|
573
|
+
handle.run_dir / "review",
|
|
574
|
+
expected_source_identity=staging_identity,
|
|
575
|
+
installed_mode=0o555,
|
|
576
|
+
parent_fd=handle.run_fd,
|
|
577
|
+
source_fd=staging_fd,
|
|
578
|
+
)
|
|
579
|
+
except FileExistsError as exc:
|
|
580
|
+
raise ReviewError(
|
|
581
|
+
"review envelope already exists and cannot be overwritten", code="ENVELOPE_EXISTS"
|
|
582
|
+
) from exc
|
|
583
|
+
except Exception:
|
|
584
|
+
installed_identity_owned = _named_entry_has_identity(
|
|
585
|
+
handle.run_fd,
|
|
586
|
+
"review",
|
|
587
|
+
staging_identity,
|
|
588
|
+
)
|
|
589
|
+
raise
|
|
590
|
+
renamed = True
|
|
591
|
+
installed_identity_owned = True
|
|
592
|
+
_ensure_installed_review_durable(handle, retained_review_fd=staging_fd)
|
|
593
|
+
installation_durability_confirmed = True
|
|
594
|
+
validate_transaction()
|
|
595
|
+
verified = _verify_review_decision(
|
|
596
|
+
handle.run_dir,
|
|
597
|
+
trusted_verifier_public_key=verifier_public,
|
|
598
|
+
trusted_assignment_value=assignment,
|
|
599
|
+
_run_handle=handle,
|
|
600
|
+
_retained_review_fd=staging_fd,
|
|
601
|
+
_expected_review_identity=staging_identity,
|
|
602
|
+
_retained_snapshot_leases=verification_leases,
|
|
603
|
+
_transaction_validator=validate_transaction,
|
|
604
|
+
)
|
|
605
|
+
if (
|
|
606
|
+
decision_digest != expected_decision_digest
|
|
607
|
+
or verified.decision_digest != expected_decision_digest
|
|
608
|
+
or verified.status != status
|
|
609
|
+
):
|
|
610
|
+
raise ReviewError(
|
|
611
|
+
"installed review did not verify as the finalized decision",
|
|
612
|
+
code="DECISION_NOT_VERIFIED",
|
|
613
|
+
)
|
|
614
|
+
for lease in verification_leases:
|
|
615
|
+
lease.validate()
|
|
616
|
+
validate_transaction()
|
|
617
|
+
result = verified
|
|
618
|
+
for lease in verification_leases:
|
|
619
|
+
lease.validate()
|
|
620
|
+
return result
|
|
621
|
+
except Exception:
|
|
622
|
+
if staging_identity is not None:
|
|
623
|
+
_remove_at(
|
|
624
|
+
handle.run_fd,
|
|
625
|
+
staging_name,
|
|
626
|
+
expected_identity=staging_identity,
|
|
627
|
+
)
|
|
628
|
+
if (renamed or installed_identity_owned) and installation_durability_confirmed:
|
|
629
|
+
_remove_at(
|
|
630
|
+
handle.run_fd,
|
|
631
|
+
"review",
|
|
632
|
+
expected_identity=staging_identity,
|
|
633
|
+
)
|
|
634
|
+
raise
|
|
635
|
+
finally:
|
|
636
|
+
for lease in reversed(verification_leases):
|
|
637
|
+
lease.close()
|
|
638
|
+
if staging_fd >= 0:
|
|
639
|
+
os.close(staging_fd)
|
|
640
|
+
if publication_lock is not None:
|
|
641
|
+
publication_lock.close()
|
|
642
|
+
handle.close()
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def verify_enrolled_review_decision(
|
|
646
|
+
run_dir: Path,
|
|
647
|
+
*,
|
|
648
|
+
review_authority_provider: TrustedReviewAuthorityProvider | None,
|
|
649
|
+
) -> ReviewResult:
|
|
650
|
+
"""Verify one installed review only through an inherited coordinator session.
|
|
651
|
+
|
|
652
|
+
This is the read-only promotion gate for callers such as deployment planning. It deliberately
|
|
653
|
+
accepts a provider rather than raw keys or assignments, so an ordinary caller cannot downgrade
|
|
654
|
+
protected review authority into caller-authored trust material.
|
|
655
|
+
"""
|
|
656
|
+
|
|
657
|
+
_authority, assignment, verifier = _review_authority_material(review_authority_provider)
|
|
658
|
+
return _verify_review_decision(
|
|
659
|
+
run_dir,
|
|
660
|
+
trusted_verifier_public_key=verifier.public_key(),
|
|
661
|
+
trusted_assignment_value=assignment,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _verify_review_decision(
|
|
666
|
+
run_dir: Path,
|
|
667
|
+
*,
|
|
668
|
+
trusted_verifier_public_key: Ed25519PublicKey,
|
|
669
|
+
trusted_assignment_value: Any | None = None,
|
|
670
|
+
_run_parent_fd: int | None = None,
|
|
671
|
+
_ancestor_validator: Any | None = None,
|
|
672
|
+
_run_handle: _ReviewRunHandle | None = None,
|
|
673
|
+
_retained_review_fd: int | None = None,
|
|
674
|
+
_expected_review_identity: tuple[int, int, int] | None = None,
|
|
675
|
+
_retained_snapshot_leases: list[Any] | None = None,
|
|
676
|
+
_transaction_validator: Any | None = None,
|
|
677
|
+
) -> ReviewResult:
|
|
678
|
+
"""Return authoritative status/digest evidence at a bound-snapshot linearization.
|
|
679
|
+
|
|
680
|
+
The result remains the evidence if a same-UID actor mutates the pathnames after
|
|
681
|
+
the final no-write observation.
|
|
682
|
+
|
|
683
|
+
The two revalidation callbacks are not interchangeable. _transaction_validator
|
|
684
|
+
revalidates the whole enclosing transaction, including the publication lock a
|
|
685
|
+
finalizer holds. _ancestor_validator revalidates only the caller's outer
|
|
686
|
+
ancestors, so it is accepted alone and wrapped with this function's own
|
|
687
|
+
run-handle check.
|
|
688
|
+
"""
|
|
689
|
+
|
|
690
|
+
if not _secure_review_verification_supported():
|
|
691
|
+
raise ReviewError(
|
|
692
|
+
"secure descriptor-relative review verification is unavailable on this platform",
|
|
693
|
+
code="PLATFORM_REVIEW_VERIFY_UNSUPPORTED",
|
|
694
|
+
)
|
|
695
|
+
owned_handle = _run_handle is None
|
|
696
|
+
handle = _run_handle or (
|
|
697
|
+
_open_review_run_handle(run_dir)
|
|
698
|
+
if _run_parent_fd is None
|
|
699
|
+
else _open_review_run_handle_from_parent(run_dir, _run_parent_fd)
|
|
700
|
+
)
|
|
701
|
+
transaction_validator = _transaction_validator
|
|
702
|
+
if transaction_validator is None and _ancestor_validator is not None:
|
|
703
|
+
|
|
704
|
+
def transaction_validator() -> None:
|
|
705
|
+
_ancestor_validator()
|
|
706
|
+
_validate_review_run_handle(handle)
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
return _verify_review_decision_with_handle(
|
|
710
|
+
handle,
|
|
711
|
+
trusted_verifier_public_key=trusted_verifier_public_key,
|
|
712
|
+
trusted_assignment_value=trusted_assignment_value,
|
|
713
|
+
retained_review_fd=_retained_review_fd,
|
|
714
|
+
expected_review_identity=_expected_review_identity,
|
|
715
|
+
retained_snapshot_leases=_retained_snapshot_leases,
|
|
716
|
+
transaction_validator=transaction_validator,
|
|
717
|
+
)
|
|
718
|
+
finally:
|
|
719
|
+
if owned_handle:
|
|
720
|
+
handle.close()
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _verify_review_decision_with_handle(
|
|
724
|
+
handle: _ReviewRunHandle,
|
|
725
|
+
*,
|
|
726
|
+
trusted_verifier_public_key: Ed25519PublicKey,
|
|
727
|
+
trusted_assignment_value: Any | None,
|
|
728
|
+
retained_review_fd: int | None,
|
|
729
|
+
expected_review_identity: tuple[int, int, int] | None,
|
|
730
|
+
retained_snapshot_leases: list[Any] | None,
|
|
731
|
+
transaction_validator: Any | None,
|
|
732
|
+
) -> ReviewResult:
|
|
733
|
+
"""Verify one review from an already-bound absolute run-directory chain."""
|
|
734
|
+
|
|
735
|
+
validate_transaction = transaction_validator or (lambda: _validate_review_run_handle(handle))
|
|
736
|
+
validate_transaction()
|
|
737
|
+
candidate_leases: list[Any] = []
|
|
738
|
+
verified_candidate = verify_candidate(
|
|
739
|
+
handle.run_dir,
|
|
740
|
+
_run_fd=handle.run_fd,
|
|
741
|
+
_ancestor_validator=validate_transaction,
|
|
742
|
+
_retained_leases=candidate_leases,
|
|
743
|
+
)
|
|
744
|
+
if len(candidate_leases) != 1:
|
|
745
|
+
for lease in candidate_leases:
|
|
746
|
+
lease.close()
|
|
747
|
+
raise ReviewError(
|
|
748
|
+
"candidate verification did not retain its exact snapshot",
|
|
749
|
+
code="DECISION_SNAPSHOT_MISSING",
|
|
750
|
+
)
|
|
751
|
+
candidate_lease = candidate_leases[0]
|
|
752
|
+
review_lease: _ReviewSnapshotLease | None = None
|
|
753
|
+
try:
|
|
754
|
+
validate_transaction()
|
|
755
|
+
envelope, review_lease = _read_sealed_review_tree(
|
|
756
|
+
handle,
|
|
757
|
+
retained_review_fd=retained_review_fd,
|
|
758
|
+
expected_review_identity=expected_review_identity,
|
|
759
|
+
)
|
|
760
|
+
result = _verify_review_snapshot(
|
|
761
|
+
handle=handle,
|
|
762
|
+
verified_candidate=verified_candidate,
|
|
763
|
+
envelope=envelope,
|
|
764
|
+
trusted_verifier_public_key=trusted_verifier_public_key,
|
|
765
|
+
trusted_assignment_value=trusted_assignment_value,
|
|
766
|
+
)
|
|
767
|
+
candidate_lease.validate()
|
|
768
|
+
review_lease.validate()
|
|
769
|
+
validate_transaction()
|
|
770
|
+
if retained_snapshot_leases is not None:
|
|
771
|
+
retained_snapshot_leases.extend([candidate_lease, review_lease])
|
|
772
|
+
return result
|
|
773
|
+
review_lease.close()
|
|
774
|
+
candidate_lease.close()
|
|
775
|
+
return result
|
|
776
|
+
except Exception:
|
|
777
|
+
if review_lease is not None:
|
|
778
|
+
review_lease.close()
|
|
779
|
+
candidate_lease.close()
|
|
780
|
+
raise
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _verify_review_snapshot(
|
|
784
|
+
*,
|
|
785
|
+
handle: _ReviewRunHandle,
|
|
786
|
+
verified_candidate: Any,
|
|
787
|
+
envelope: dict[str, bytes],
|
|
788
|
+
trusted_verifier_public_key: Ed25519PublicKey,
|
|
789
|
+
trusted_assignment_value: Any | None,
|
|
790
|
+
) -> ReviewResult:
|
|
791
|
+
"""Authenticate one retained candidate/review byte snapshot."""
|
|
792
|
+
|
|
793
|
+
review_dir = handle.run_dir / "review"
|
|
794
|
+
assignment = _decode_json(envelope["assignment.json"], "assignment")
|
|
795
|
+
assignment = _parse_assignment(assignment)
|
|
796
|
+
if trusted_assignment_value is not None:
|
|
797
|
+
trusted_assignment = _parse_assignment(trusted_assignment_value)
|
|
798
|
+
if canonical_json_line_bytes(assignment) != canonical_json_line_bytes(trusted_assignment):
|
|
799
|
+
raise ReviewError(
|
|
800
|
+
"review assignment is not the trusted coordinator enrollment",
|
|
801
|
+
code="ASSIGNMENT_NOT_ENROLLED",
|
|
802
|
+
)
|
|
803
|
+
if assignment["verifier"]["public_key"] != public_key_to_base64(trusted_verifier_public_key):
|
|
804
|
+
raise ReviewError(
|
|
805
|
+
"review verifier is not the trusted enrolled key",
|
|
806
|
+
code="ASSIGNMENT_VERIFIER_NOT_ENROLLED",
|
|
807
|
+
)
|
|
808
|
+
_validate_assignment_against_candidate(assignment, verified_candidate)
|
|
809
|
+
|
|
810
|
+
reports_by_lens = {
|
|
811
|
+
lens: _parse_signed_report(_decode_json(envelope[f"reports/{lens}.json"], f"report {lens}"))
|
|
812
|
+
for lens in REQUIRED_LENSES
|
|
813
|
+
}
|
|
814
|
+
assignments_by_lens = {item["lens"]: item for item in assignment["reviewers"]}
|
|
815
|
+
for lens, report in reports_by_lens.items():
|
|
816
|
+
_verify_report(report, assignments_by_lens[lens], verified_candidate, assignment)
|
|
817
|
+
expected_findings = _normalize_findings(reports_by_lens)
|
|
818
|
+
findings = _decode_json(envelope["findings.json"], "findings")
|
|
819
|
+
if findings != expected_findings:
|
|
820
|
+
raise ReviewError("normalized findings were changed", code="DECISION_FINDINGS_CHANGED")
|
|
821
|
+
expected_backlog = [
|
|
822
|
+
finding for finding in expected_findings if finding["severity"] in {"medium", "low"}
|
|
823
|
+
]
|
|
824
|
+
backlog = _decode_json(envelope["backlog.json"], "backlog")
|
|
825
|
+
if backlog != expected_backlog:
|
|
826
|
+
raise ReviewError("review backlog was changed", code="DECISION_BACKLOG_CHANGED")
|
|
827
|
+
|
|
828
|
+
decision = _decode_json(envelope["decision.json"], "decision")
|
|
829
|
+
if not isinstance(decision, dict) or set(decision) != {
|
|
830
|
+
"schema_version",
|
|
831
|
+
"candidate_digest",
|
|
832
|
+
"table_sha256",
|
|
833
|
+
"validation_policy_digest",
|
|
834
|
+
"assignment_sha256",
|
|
835
|
+
"report_sha256s",
|
|
836
|
+
"findings_sha256",
|
|
837
|
+
"backlog_sha256",
|
|
838
|
+
"cycle_lineage",
|
|
839
|
+
"failed_cycle_count",
|
|
840
|
+
"verifier_version",
|
|
841
|
+
"verifier_principal",
|
|
842
|
+
"status",
|
|
843
|
+
"decision_digest",
|
|
844
|
+
"signature",
|
|
845
|
+
}:
|
|
846
|
+
raise ReviewError("review decision fields are invalid", code="DECISION_FIELDS_INVALID")
|
|
847
|
+
signed_body = {key: value for key, value in decision.items() if key != "signature"}
|
|
848
|
+
payload = {key: value for key, value in signed_body.items() if key != "decision_digest"}
|
|
849
|
+
if decision["decision_digest"] != sha256_bytes(canonical_json_line_bytes(payload)):
|
|
850
|
+
raise ReviewError("review decision digest is invalid", code="DECISION_DIGEST_INVALID")
|
|
851
|
+
expected_hashes = {
|
|
852
|
+
"assignment_sha256": sha256_bytes(envelope["assignment.json"]),
|
|
853
|
+
"report_sha256s": {
|
|
854
|
+
lens: sha256_bytes(envelope[f"reports/{lens}.json"]) for lens in sorted(REQUIRED_LENSES)
|
|
855
|
+
},
|
|
856
|
+
"findings_sha256": sha256_bytes(envelope["findings.json"]),
|
|
857
|
+
"backlog_sha256": sha256_bytes(envelope["backlog.json"]),
|
|
858
|
+
}
|
|
859
|
+
for field, expected in expected_hashes.items():
|
|
860
|
+
if decision[field] != expected:
|
|
861
|
+
raise ReviewError(
|
|
862
|
+
f"review decision {field} does not match its envelope",
|
|
863
|
+
code="DECISION_FIELD_MISMATCH",
|
|
864
|
+
)
|
|
865
|
+
expected_status = _derive_review_status(findings, assignment["failed_cycle_count"])
|
|
866
|
+
expected_payload_values = {
|
|
867
|
+
"schema_version": DECISION_SCHEMA_VERSION,
|
|
868
|
+
"candidate_digest": verified_candidate.candidate_digest,
|
|
869
|
+
"table_sha256": verified_candidate.table_sha256,
|
|
870
|
+
"validation_policy_digest": verified_candidate.validation_policy_digest,
|
|
871
|
+
"cycle_lineage": assignment["cycle_lineage"],
|
|
872
|
+
"failed_cycle_count": assignment["failed_cycle_count"],
|
|
873
|
+
"verifier_version": VERIFIER_VERSION,
|
|
874
|
+
"verifier_principal": assignment["verifier"]["principal_id"],
|
|
875
|
+
"status": expected_status,
|
|
876
|
+
}
|
|
877
|
+
for field, expected in expected_payload_values.items():
|
|
878
|
+
if decision[field] != expected:
|
|
879
|
+
raise ReviewError(f"review decision {field} is invalid", code="DECISION_FIELD_INVALID")
|
|
880
|
+
try:
|
|
881
|
+
signature = base64.b64decode(decision["signature"], validate=True)
|
|
882
|
+
trusted_verifier_public_key.verify(signature, canonical_json_line_bytes(signed_body))
|
|
883
|
+
except (InvalidSignature, ValueError, TypeError) as exc:
|
|
884
|
+
raise ReviewError(
|
|
885
|
+
"review decision signature is invalid", code="DECISION_SIGNATURE_INVALID"
|
|
886
|
+
) from exc
|
|
887
|
+
_validate_review_run_handle(handle)
|
|
888
|
+
return ReviewResult(
|
|
889
|
+
status=expected_status,
|
|
890
|
+
candidate_digest=verified_candidate.candidate_digest,
|
|
891
|
+
decision_digest=decision["decision_digest"],
|
|
892
|
+
review_dir=review_dir,
|
|
893
|
+
decision_document=dict(decision),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _parse_assignment(value: Any) -> dict[str, Any]:
|
|
898
|
+
data = _object(value, "assignment")
|
|
899
|
+
_exact_fields(
|
|
900
|
+
data,
|
|
901
|
+
{
|
|
902
|
+
"schema_version",
|
|
903
|
+
"candidate_digest",
|
|
904
|
+
"validation_policy_digest",
|
|
905
|
+
"cycle_lineage",
|
|
906
|
+
"failed_cycle_count",
|
|
907
|
+
"producer",
|
|
908
|
+
"reviewers",
|
|
909
|
+
"verifier",
|
|
910
|
+
},
|
|
911
|
+
"assignment",
|
|
912
|
+
)
|
|
913
|
+
if data["schema_version"] != ASSIGNMENT_SCHEMA_VERSION:
|
|
914
|
+
raise ReviewError(
|
|
915
|
+
"assignment schema version is unsupported", code="ASSIGNMENT_VERSION_UNSUPPORTED"
|
|
916
|
+
)
|
|
917
|
+
normalized = {
|
|
918
|
+
"schema_version": ASSIGNMENT_SCHEMA_VERSION,
|
|
919
|
+
"candidate_digest": _digest(data["candidate_digest"], "candidate_digest"),
|
|
920
|
+
"validation_policy_digest": _digest(
|
|
921
|
+
data["validation_policy_digest"], "validation_policy_digest"
|
|
922
|
+
),
|
|
923
|
+
"cycle_lineage": _identifier(data["cycle_lineage"], "cycle_lineage"),
|
|
924
|
+
"failed_cycle_count": data["failed_cycle_count"],
|
|
925
|
+
"producer": _parse_principal_attempt(data["producer"], "producer"),
|
|
926
|
+
"reviewers": _parse_reviewers(data["reviewers"]),
|
|
927
|
+
"verifier": _parse_verifier(data["verifier"]),
|
|
928
|
+
}
|
|
929
|
+
if type(normalized["failed_cycle_count"]) is not int or normalized["failed_cycle_count"] != 0:
|
|
930
|
+
raise ReviewError(
|
|
931
|
+
"failed_cycle_count must be 0 for the current initial-candidate review schema",
|
|
932
|
+
code="ASSIGNMENT_CYCLE_COUNT_INVALID",
|
|
933
|
+
)
|
|
934
|
+
reviewers = normalized["reviewers"]
|
|
935
|
+
principals = [
|
|
936
|
+
normalized["producer"]["principal_id"],
|
|
937
|
+
*(item["principal_id"] for item in reviewers),
|
|
938
|
+
normalized["verifier"]["principal_id"],
|
|
939
|
+
]
|
|
940
|
+
if len(principals) != len(set(principals)):
|
|
941
|
+
raise ReviewError(
|
|
942
|
+
"producer, reviewers, and verifier must be distinct principals",
|
|
943
|
+
code="ASSIGNMENT_PRINCIPALS_NOT_DISTINCT",
|
|
944
|
+
)
|
|
945
|
+
attempts = [normalized["producer"]["attempt_id"], *(item["attempt_id"] for item in reviewers)]
|
|
946
|
+
if len(attempts) != len(set(attempts)):
|
|
947
|
+
raise ReviewError(
|
|
948
|
+
"producer and reviewer attempts must be unique", code="ASSIGNMENT_ATTEMPTS_NOT_DISTINCT"
|
|
949
|
+
)
|
|
950
|
+
keys = [item["public_key"] for item in reviewers] + [normalized["verifier"]["public_key"]]
|
|
951
|
+
if len(keys) != len(set(keys)):
|
|
952
|
+
raise ReviewError(
|
|
953
|
+
"reviewer and verifier signing keys must be distinct",
|
|
954
|
+
code="ASSIGNMENT_KEYS_NOT_DISTINCT",
|
|
955
|
+
)
|
|
956
|
+
key_ids = [item["key_id"] for item in reviewers]
|
|
957
|
+
if len(key_ids) != len(set(key_ids)):
|
|
958
|
+
raise ReviewError(
|
|
959
|
+
"reviewer signing key IDs must be distinct", code="ASSIGNMENT_KEY_IDS_NOT_DISTINCT"
|
|
960
|
+
)
|
|
961
|
+
return normalized
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _parse_reviewers(value: Any) -> list[dict[str, str]]:
|
|
965
|
+
if not isinstance(value, list) or len(value) != 2:
|
|
966
|
+
raise ReviewError(
|
|
967
|
+
"assignment must contain exactly two reviewers", code="ASSIGNMENT_REVIEWER_COUNT"
|
|
968
|
+
)
|
|
969
|
+
reviewers = []
|
|
970
|
+
for index, item in enumerate(value):
|
|
971
|
+
data = _object(item, f"reviewers[{index}]")
|
|
972
|
+
_exact_fields(
|
|
973
|
+
data,
|
|
974
|
+
{"principal_id", "attempt_id", "lens", "key_id", "public_key"},
|
|
975
|
+
f"reviewers[{index}]",
|
|
976
|
+
)
|
|
977
|
+
reviewer = {
|
|
978
|
+
**_parse_principal_attempt(
|
|
979
|
+
data,
|
|
980
|
+
f"reviewers[{index}]",
|
|
981
|
+
extra={"lens", "key_id", "public_key"},
|
|
982
|
+
),
|
|
983
|
+
"lens": _choice(data["lens"], REQUIRED_LENSES, f"reviewers[{index}].lens"),
|
|
984
|
+
"key_id": _identifier(data["key_id"], f"reviewers[{index}].key_id"),
|
|
985
|
+
"public_key": _validated_public_key(data["public_key"]),
|
|
986
|
+
}
|
|
987
|
+
reviewers.append(reviewer)
|
|
988
|
+
if {reviewer["lens"] for reviewer in reviewers} != REQUIRED_LENSES:
|
|
989
|
+
raise ReviewError(
|
|
990
|
+
"assignment must cover both required lenses", code="ASSIGNMENT_LENSES_INCOMPLETE"
|
|
991
|
+
)
|
|
992
|
+
return sorted(reviewers, key=lambda item: item["lens"])
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _parse_verifier(value: Any) -> dict[str, str]:
|
|
996
|
+
data = _object(value, "verifier")
|
|
997
|
+
_exact_fields(data, {"principal_id", "public_key"}, "verifier")
|
|
998
|
+
return {
|
|
999
|
+
"principal_id": _identifier(data["principal_id"], "verifier.principal_id"),
|
|
1000
|
+
"public_key": _validated_public_key(data["public_key"]),
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _parse_principal_attempt(
|
|
1005
|
+
value: Any, name: str, *, extra: set[str] | None = None
|
|
1006
|
+
) -> dict[str, str]:
|
|
1007
|
+
data = _object(value, name)
|
|
1008
|
+
fields = {"principal_id", "attempt_id", *(extra or set())}
|
|
1009
|
+
_exact_fields(data, fields, name)
|
|
1010
|
+
return {
|
|
1011
|
+
"principal_id": _identifier(data["principal_id"], f"{name}.principal_id"),
|
|
1012
|
+
"attempt_id": _identifier(data["attempt_id"], f"{name}.attempt_id"),
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _parse_report_payload(value: Any) -> dict[str, Any]:
|
|
1017
|
+
data = _object(value, "report")
|
|
1018
|
+
_exact_fields(
|
|
1019
|
+
data,
|
|
1020
|
+
{
|
|
1021
|
+
"schema_version",
|
|
1022
|
+
"assignment_digest",
|
|
1023
|
+
"candidate_digest",
|
|
1024
|
+
"validation_policy_digest",
|
|
1025
|
+
"cycle_lineage",
|
|
1026
|
+
"failed_cycle_count",
|
|
1027
|
+
"reviewer_principal",
|
|
1028
|
+
"reviewer_attempt",
|
|
1029
|
+
"reviewer_key_id",
|
|
1030
|
+
"lens",
|
|
1031
|
+
"reviewed_artifact_paths",
|
|
1032
|
+
"summary",
|
|
1033
|
+
"findings",
|
|
1034
|
+
},
|
|
1035
|
+
"report",
|
|
1036
|
+
)
|
|
1037
|
+
if data["schema_version"] != REPORT_SCHEMA_VERSION:
|
|
1038
|
+
raise ReviewError("report schema version is unsupported", code="REPORT_VERSION_UNSUPPORTED")
|
|
1039
|
+
findings = data["findings"]
|
|
1040
|
+
if not isinstance(findings, list):
|
|
1041
|
+
raise ReviewError("report findings must be an array", code="REPORT_FINDINGS_INVALID")
|
|
1042
|
+
failed_cycle_count = data["failed_cycle_count"]
|
|
1043
|
+
if type(failed_cycle_count) is not int or failed_cycle_count != 0:
|
|
1044
|
+
raise ReviewError(
|
|
1045
|
+
"report failed_cycle_count must be 0 for the current initial-candidate review schema",
|
|
1046
|
+
code="REPORT_CYCLE_COUNT_INVALID",
|
|
1047
|
+
)
|
|
1048
|
+
reviewed_artifact_paths = _parse_artifact_paths(data["reviewed_artifact_paths"])
|
|
1049
|
+
normalized_findings = [
|
|
1050
|
+
_parse_finding(item, index, data["reviewer_principal"])
|
|
1051
|
+
for index, item in enumerate(findings)
|
|
1052
|
+
]
|
|
1053
|
+
ids = [finding["finding_id"] for finding in normalized_findings]
|
|
1054
|
+
if len(ids) != len(set(ids)):
|
|
1055
|
+
raise ReviewError(
|
|
1056
|
+
"finding IDs must be unique within a report", code="REPORT_FINDING_IDS_DUPLICATE"
|
|
1057
|
+
)
|
|
1058
|
+
return {
|
|
1059
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
1060
|
+
"assignment_digest": _digest(data["assignment_digest"], "assignment_digest"),
|
|
1061
|
+
"candidate_digest": _digest(data["candidate_digest"], "candidate_digest"),
|
|
1062
|
+
"validation_policy_digest": _digest(
|
|
1063
|
+
data["validation_policy_digest"], "validation_policy_digest"
|
|
1064
|
+
),
|
|
1065
|
+
"cycle_lineage": _identifier(data["cycle_lineage"], "cycle_lineage"),
|
|
1066
|
+
"failed_cycle_count": failed_cycle_count,
|
|
1067
|
+
"reviewer_principal": _identifier(data["reviewer_principal"], "reviewer_principal"),
|
|
1068
|
+
"reviewer_attempt": _identifier(data["reviewer_attempt"], "reviewer_attempt"),
|
|
1069
|
+
"reviewer_key_id": _identifier(data["reviewer_key_id"], "reviewer_key_id"),
|
|
1070
|
+
"lens": _choice(data["lens"], REQUIRED_LENSES, "lens"),
|
|
1071
|
+
"reviewed_artifact_paths": reviewed_artifact_paths,
|
|
1072
|
+
"summary": _text(data["summary"], "summary"),
|
|
1073
|
+
"findings": normalized_findings,
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _parse_signed_report(value: Any) -> dict[str, Any]:
|
|
1078
|
+
data = _object(value, "signed report")
|
|
1079
|
+
if "signature" not in data:
|
|
1080
|
+
raise ReviewError("signed report is missing signature", code="REPORT_SIGNATURE_MISSING")
|
|
1081
|
+
payload = _parse_report_payload({key: val for key, val in data.items() if key != "signature"})
|
|
1082
|
+
signature = data["signature"]
|
|
1083
|
+
if not isinstance(signature, str):
|
|
1084
|
+
raise ReviewError("report signature must be base64 text", code="REPORT_SIGNATURE_INVALID")
|
|
1085
|
+
try:
|
|
1086
|
+
base64.b64decode(signature, validate=True)
|
|
1087
|
+
except (ValueError, TypeError) as exc:
|
|
1088
|
+
raise ReviewError(
|
|
1089
|
+
"report signature is invalid base64", code="REPORT_SIGNATURE_INVALID"
|
|
1090
|
+
) from exc
|
|
1091
|
+
return {**payload, "signature": signature}
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _derive_review_status(findings: list[dict[str, Any]], failed_cycle_count: int) -> str:
|
|
1095
|
+
"""Derive one canonical review status for finalization and later verification."""
|
|
1096
|
+
|
|
1097
|
+
if type(failed_cycle_count) is not int or failed_cycle_count != 0:
|
|
1098
|
+
raise ReviewError(
|
|
1099
|
+
"failed_cycle_count must be 0 for the current initial-candidate review schema",
|
|
1100
|
+
code="REPORT_CYCLE_COUNT_INVALID",
|
|
1101
|
+
)
|
|
1102
|
+
has_blocking = any(finding["severity"] in _BLOCKING_SEVERITIES for finding in findings)
|
|
1103
|
+
if has_blocking:
|
|
1104
|
+
return "fixes_required"
|
|
1105
|
+
return "reviewed_candidate"
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _parse_finding(value: Any, index: int, reviewer_principal: Any) -> dict[str, Any]:
|
|
1109
|
+
name = f"findings[{index}]"
|
|
1110
|
+
data = _object(value, name)
|
|
1111
|
+
_exact_fields(
|
|
1112
|
+
data,
|
|
1113
|
+
{
|
|
1114
|
+
"finding_id",
|
|
1115
|
+
"severity",
|
|
1116
|
+
"invariant",
|
|
1117
|
+
"evidence",
|
|
1118
|
+
"affected_artifact",
|
|
1119
|
+
"reviewer",
|
|
1120
|
+
"repair_owner",
|
|
1121
|
+
"acceptance_condition",
|
|
1122
|
+
"status",
|
|
1123
|
+
"resolution_evidence",
|
|
1124
|
+
},
|
|
1125
|
+
name,
|
|
1126
|
+
)
|
|
1127
|
+
reviewer = _identifier(data["reviewer"], f"{name}.reviewer")
|
|
1128
|
+
if reviewer != reviewer_principal:
|
|
1129
|
+
raise ReviewError(
|
|
1130
|
+
f"{name}.reviewer must match the report reviewer",
|
|
1131
|
+
code="REPORT_FINDING_REVIEWER_MISMATCH",
|
|
1132
|
+
)
|
|
1133
|
+
if data["status"] != "open" or data["resolution_evidence"] is not None:
|
|
1134
|
+
raise ReviewError(
|
|
1135
|
+
f"{name} must be an open finding without resolution evidence",
|
|
1136
|
+
code="REPORT_FINDING_NOT_OPEN",
|
|
1137
|
+
)
|
|
1138
|
+
return {
|
|
1139
|
+
"finding_id": _identifier(data["finding_id"], f"{name}.finding_id"),
|
|
1140
|
+
"severity": _choice(data["severity"], _SEVERITIES, f"{name}.severity"),
|
|
1141
|
+
"invariant": _text(data["invariant"], f"{name}.invariant"),
|
|
1142
|
+
"evidence": _text(data["evidence"], f"{name}.evidence"),
|
|
1143
|
+
"affected_artifact": _text(data["affected_artifact"], f"{name}.affected_artifact"),
|
|
1144
|
+
"reviewer": reviewer,
|
|
1145
|
+
"repair_owner": _identifier(data["repair_owner"], f"{name}.repair_owner"),
|
|
1146
|
+
"acceptance_condition": _text(data["acceptance_condition"], f"{name}.acceptance_condition"),
|
|
1147
|
+
"status": "open",
|
|
1148
|
+
"resolution_evidence": None,
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _verify_report(
|
|
1153
|
+
report: dict[str, Any],
|
|
1154
|
+
reviewer_assignment: dict[str, str],
|
|
1155
|
+
candidate: Any,
|
|
1156
|
+
assignment: dict[str, Any],
|
|
1157
|
+
) -> None:
|
|
1158
|
+
expected = {
|
|
1159
|
+
"assignment_digest": _normalized_assignment_digest(assignment),
|
|
1160
|
+
"candidate_digest": candidate.candidate_digest,
|
|
1161
|
+
"validation_policy_digest": candidate.validation_policy_digest,
|
|
1162
|
+
"cycle_lineage": assignment["cycle_lineage"],
|
|
1163
|
+
"failed_cycle_count": assignment["failed_cycle_count"],
|
|
1164
|
+
"reviewer_principal": reviewer_assignment["principal_id"],
|
|
1165
|
+
"reviewer_attempt": reviewer_assignment["attempt_id"],
|
|
1166
|
+
"reviewer_key_id": reviewer_assignment["key_id"],
|
|
1167
|
+
"lens": reviewer_assignment["lens"],
|
|
1168
|
+
"reviewed_artifact_paths": _candidate_artifact_paths(candidate),
|
|
1169
|
+
}
|
|
1170
|
+
for field, value in expected.items():
|
|
1171
|
+
if report[field] != value:
|
|
1172
|
+
raise ReviewError(
|
|
1173
|
+
f"report {field} does not match its assignment/candidate",
|
|
1174
|
+
code="REPORT_FIELD_MISMATCH",
|
|
1175
|
+
)
|
|
1176
|
+
payload = {key: value for key, value in report.items() if key != "signature"}
|
|
1177
|
+
try:
|
|
1178
|
+
signature = base64.b64decode(report["signature"], validate=True)
|
|
1179
|
+
load_public_key_base64(reviewer_assignment["public_key"]).verify(
|
|
1180
|
+
signature, canonical_json_line_bytes(payload)
|
|
1181
|
+
)
|
|
1182
|
+
except (InvalidSignature, ValueError, TypeError) as exc:
|
|
1183
|
+
raise ReviewError(
|
|
1184
|
+
"review report signature is invalid", code="REPORT_SIGNATURE_INVALID"
|
|
1185
|
+
) from exc
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _normalize_findings(reports_by_lens: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1189
|
+
findings = [
|
|
1190
|
+
finding for lens in sorted(reports_by_lens) for finding in reports_by_lens[lens]["findings"]
|
|
1191
|
+
]
|
|
1192
|
+
ids = [finding["finding_id"] for finding in findings]
|
|
1193
|
+
if len(ids) != len(set(ids)):
|
|
1194
|
+
raise ReviewError(
|
|
1195
|
+
"finding IDs must be unique across reports", code="REPORT_FINDING_IDS_DUPLICATE"
|
|
1196
|
+
)
|
|
1197
|
+
return sorted(findings, key=lambda finding: finding["finding_id"])
|
|
1198
|
+
|
|
1199
|
+
|
|
1200
|
+
def _validate_assignment_against_candidate(assignment: dict[str, Any], candidate: Any) -> None:
|
|
1201
|
+
if assignment["candidate_digest"] != candidate.candidate_digest:
|
|
1202
|
+
raise ReviewError(
|
|
1203
|
+
"assignment candidate digest does not match", code="ASSIGNMENT_CANDIDATE_MISMATCH"
|
|
1204
|
+
)
|
|
1205
|
+
if assignment["validation_policy_digest"] != candidate.validation_policy_digest:
|
|
1206
|
+
raise ReviewError(
|
|
1207
|
+
"assignment validation policy digest does not match", code="ASSIGNMENT_POLICY_MISMATCH"
|
|
1208
|
+
)
|
|
1209
|
+
if candidate.producer_principal != assignment["producer"]["principal_id"]:
|
|
1210
|
+
raise ReviewError(
|
|
1211
|
+
"assignment producer principal does not match sealed execution",
|
|
1212
|
+
code="ASSIGNMENT_PRODUCER_MISMATCH",
|
|
1213
|
+
)
|
|
1214
|
+
if candidate.producer_attempt != assignment["producer"]["attempt_id"]:
|
|
1215
|
+
raise ReviewError(
|
|
1216
|
+
"assignment producer attempt does not match sealed execution",
|
|
1217
|
+
code="ASSIGNMENT_PRODUCER_ATTEMPT_MISMATCH",
|
|
1218
|
+
)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _normalized_assignment_digest(assignment: dict[str, Any]) -> str:
|
|
1222
|
+
return sha256_bytes(canonical_json_line_bytes(assignment))
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
def _candidate_artifact_paths(candidate: Any) -> list[str]:
|
|
1226
|
+
members = candidate.manifest.get("members")
|
|
1227
|
+
if not isinstance(members, list): # verify_candidate should make this unreachable
|
|
1228
|
+
raise ReviewError(
|
|
1229
|
+
"verified candidate does not expose its artifact members",
|
|
1230
|
+
code="CANDIDATE_MEMBER_MISMATCH",
|
|
1231
|
+
)
|
|
1232
|
+
paths = ["candidate/manifest.json"]
|
|
1233
|
+
for member in members:
|
|
1234
|
+
if not isinstance(member, dict) or not isinstance(member.get("path"), str):
|
|
1235
|
+
raise ReviewError(
|
|
1236
|
+
"verified candidate contains an invalid artifact member",
|
|
1237
|
+
code="CANDIDATE_MEMBER_INVALID",
|
|
1238
|
+
)
|
|
1239
|
+
paths.append(f"candidate/{member['path']}")
|
|
1240
|
+
return sorted(paths)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _parse_artifact_paths(value: Any) -> list[str]:
|
|
1244
|
+
if not isinstance(value, list) or not value:
|
|
1245
|
+
raise ReviewError(
|
|
1246
|
+
"reviewed_artifact_paths must be a non-empty array",
|
|
1247
|
+
code="REPORT_ARTIFACT_PATHS_INVALID",
|
|
1248
|
+
)
|
|
1249
|
+
paths: list[str] = []
|
|
1250
|
+
for index, item in enumerate(value):
|
|
1251
|
+
if not isinstance(item, str) or "\\" in item:
|
|
1252
|
+
raise ReviewError(
|
|
1253
|
+
f"reviewed_artifact_paths[{index}] is invalid", code="REPORT_ARTIFACT_PATHS_INVALID"
|
|
1254
|
+
)
|
|
1255
|
+
path = PurePosixPath(item)
|
|
1256
|
+
if (
|
|
1257
|
+
path.is_absolute()
|
|
1258
|
+
or not path.parts
|
|
1259
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
1260
|
+
):
|
|
1261
|
+
raise ReviewError(
|
|
1262
|
+
f"reviewed_artifact_paths[{index}] is not normalized and relative",
|
|
1263
|
+
code="REPORT_ARTIFACT_PATHS_INVALID",
|
|
1264
|
+
)
|
|
1265
|
+
paths.append(path.as_posix())
|
|
1266
|
+
if paths != sorted(set(paths)):
|
|
1267
|
+
raise ReviewError(
|
|
1268
|
+
"reviewed_artifact_paths must be unique and sorted",
|
|
1269
|
+
code="REPORT_ARTIFACT_PATHS_INVALID",
|
|
1270
|
+
)
|
|
1271
|
+
return paths
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
def _validated_public_key(value: Any) -> str:
|
|
1275
|
+
if not isinstance(value, str):
|
|
1276
|
+
raise ReviewError("public key must be base64 text", code="KEY_ENCODING_INVALID")
|
|
1277
|
+
load_public_key_base64(value)
|
|
1278
|
+
return value
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _object(value: Any, name: str) -> dict[str, Any]:
|
|
1282
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
1283
|
+
raise ReviewError(
|
|
1284
|
+
f"{name} must be an object with string keys", code="ENVELOPE_FIELD_NOT_AN_OBJECT"
|
|
1285
|
+
)
|
|
1286
|
+
return value
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _exact_fields(data: dict[str, Any], expected: set[str], name: str) -> None:
|
|
1290
|
+
if set(data) != expected:
|
|
1291
|
+
raise ReviewError(f"{name} fields are invalid", code="ENVELOPE_FIELDS_INVALID")
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
def _identifier(value: Any, name: str) -> str:
|
|
1295
|
+
if not isinstance(value, str) or not value:
|
|
1296
|
+
raise ReviewError(
|
|
1297
|
+
f"{name} must be a non-empty identifier", code="ENVELOPE_FIELD_NOT_AN_IDENTIFIER"
|
|
1298
|
+
)
|
|
1299
|
+
candidate = value.replace("-", "a").replace("_", "a")
|
|
1300
|
+
if not candidate.isalnum():
|
|
1301
|
+
raise ReviewError(
|
|
1302
|
+
f"{name} contains unsupported characters", code="ENVELOPE_FIELD_UNSUPPORTED_CHARACTERS"
|
|
1303
|
+
)
|
|
1304
|
+
return value
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def _text(value: Any, name: str) -> str:
|
|
1308
|
+
if not isinstance(value, str) or not value.strip():
|
|
1309
|
+
raise ReviewError(f"{name} must be non-empty text", code="ENVELOPE_FIELD_EMPTY")
|
|
1310
|
+
return value
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _digest(value: Any, name: str) -> str:
|
|
1314
|
+
if (
|
|
1315
|
+
not isinstance(value, str)
|
|
1316
|
+
or len(value) != 64
|
|
1317
|
+
or any(char not in "0123456789abcdef" for char in value)
|
|
1318
|
+
):
|
|
1319
|
+
raise ReviewError(
|
|
1320
|
+
f"{name} must be a lowercase SHA-256 digest", code="ENVELOPE_FIELD_NOT_A_DIGEST"
|
|
1321
|
+
)
|
|
1322
|
+
return value
|
|
1323
|
+
|
|
1324
|
+
|
|
1325
|
+
def _choice(value: Any, choices: frozenset[str], name: str) -> str:
|
|
1326
|
+
if not isinstance(value, str) or value not in choices:
|
|
1327
|
+
raise ReviewError(
|
|
1328
|
+
f"{name} must be one of {sorted(choices)}", code="ENVELOPE_FIELD_NOT_A_CHOICE"
|
|
1329
|
+
)
|
|
1330
|
+
return value
|
|
1331
|
+
|
|
1332
|
+
|
|
1333
|
+
def _decode_json(raw: bytes, name: str) -> Any:
|
|
1334
|
+
try:
|
|
1335
|
+
return json.loads(raw.decode("utf-8"))
|
|
1336
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
1337
|
+
raise ReviewError(f"{name} cannot be read", code="ENVELOPE_FILE_UNREADABLE") from exc
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _absolute_lexical_path(path: Path) -> Path:
|
|
1341
|
+
"""Return an absolute normalized path without resolving filesystem links."""
|
|
1342
|
+
|
|
1343
|
+
return Path(os.path.abspath(os.fspath(path)))
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def _open_review_run_handle(run_dir: Path) -> _ReviewRunHandle:
|
|
1347
|
+
"""Bind every absolute run ancestor and start swap/restore monitoring."""
|
|
1348
|
+
|
|
1349
|
+
if not _secure_review_verification_supported():
|
|
1350
|
+
raise ReviewError(
|
|
1351
|
+
"secure descriptor-relative review verification is unavailable on this platform",
|
|
1352
|
+
code="PLATFORM_REVIEW_VERIFY_UNSUPPORTED",
|
|
1353
|
+
)
|
|
1354
|
+
run_dir = _absolute_lexical_path(run_dir)
|
|
1355
|
+
if run_dir == Path(run_dir.anchor) or not run_dir.name:
|
|
1356
|
+
raise ReviewError(
|
|
1357
|
+
"review run directory must name a non-root directory", code="CANDIDATE_ANCESTOR_INVALID"
|
|
1358
|
+
)
|
|
1359
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
1360
|
+
descriptors: list[int] = []
|
|
1361
|
+
records: list[_RetainedAncestorRecord] = []
|
|
1362
|
+
monitor: _ReviewAncestorMonitor | None = None
|
|
1363
|
+
try:
|
|
1364
|
+
anchor_fd = os.open(run_dir.anchor, directory_flags)
|
|
1365
|
+
descriptors.append(anchor_fd)
|
|
1366
|
+
anchor_info = os.fstat(anchor_fd)
|
|
1367
|
+
if not stat.S_ISDIR(anchor_info.st_mode):
|
|
1368
|
+
raise ReviewError(
|
|
1369
|
+
"review run filesystem anchor is not a directory", code="CANDIDATE_ANCESTOR_INVALID"
|
|
1370
|
+
)
|
|
1371
|
+
records.append(
|
|
1372
|
+
(
|
|
1373
|
+
anchor_fd,
|
|
1374
|
+
None,
|
|
1375
|
+
None,
|
|
1376
|
+
_entry_identity(anchor_info),
|
|
1377
|
+
"review run filesystem anchor",
|
|
1378
|
+
)
|
|
1379
|
+
)
|
|
1380
|
+
|
|
1381
|
+
parent_fd = anchor_fd
|
|
1382
|
+
traversed = Path(run_dir.anchor)
|
|
1383
|
+
for part in run_dir.parts[1:]:
|
|
1384
|
+
traversed /= part
|
|
1385
|
+
named = os.stat(part, dir_fd=parent_fd, follow_symlinks=False)
|
|
1386
|
+
if not stat.S_ISDIR(named.st_mode):
|
|
1387
|
+
raise ReviewError(
|
|
1388
|
+
f"review run ancestor is not a directory: {traversed}",
|
|
1389
|
+
code="CANDIDATE_ANCESTOR_INVALID",
|
|
1390
|
+
)
|
|
1391
|
+
descriptor = os.open(part, directory_flags, dir_fd=parent_fd)
|
|
1392
|
+
descriptors.append(descriptor)
|
|
1393
|
+
opened = os.fstat(descriptor)
|
|
1394
|
+
_require_same_identity(named, opened, f"review run ancestor {traversed}")
|
|
1395
|
+
records.append(
|
|
1396
|
+
(
|
|
1397
|
+
descriptor,
|
|
1398
|
+
parent_fd,
|
|
1399
|
+
part,
|
|
1400
|
+
_entry_identity(opened),
|
|
1401
|
+
f"review run ancestor {traversed}",
|
|
1402
|
+
)
|
|
1403
|
+
)
|
|
1404
|
+
parent_fd = descriptor
|
|
1405
|
+
|
|
1406
|
+
monitor = _create_review_ancestor_monitor(descriptors)
|
|
1407
|
+
handle = _ReviewRunHandle(
|
|
1408
|
+
run_dir=run_dir,
|
|
1409
|
+
descriptors=descriptors,
|
|
1410
|
+
records=records,
|
|
1411
|
+
monitor=monitor,
|
|
1412
|
+
)
|
|
1413
|
+
_validate_review_run_handle(handle)
|
|
1414
|
+
return handle
|
|
1415
|
+
except ReviewError:
|
|
1416
|
+
if monitor is not None:
|
|
1417
|
+
monitor.close()
|
|
1418
|
+
for descriptor in reversed(descriptors):
|
|
1419
|
+
os.close(descriptor)
|
|
1420
|
+
raise
|
|
1421
|
+
except OSError as exc:
|
|
1422
|
+
if monitor is not None:
|
|
1423
|
+
monitor.close()
|
|
1424
|
+
for descriptor in reversed(descriptors):
|
|
1425
|
+
os.close(descriptor)
|
|
1426
|
+
raise ReviewError(
|
|
1427
|
+
"review run ancestor chain cannot be opened safely", code="CANDIDATE_ANCESTOR_INVALID"
|
|
1428
|
+
) from exc
|
|
1429
|
+
|
|
1430
|
+
|
|
1431
|
+
def _open_review_run_handle_from_parent(
|
|
1432
|
+
run_dir: Path,
|
|
1433
|
+
run_parent_fd: int,
|
|
1434
|
+
) -> _ReviewRunHandle:
|
|
1435
|
+
"""Bind a run child through an already-retained trusted parent descriptor."""
|
|
1436
|
+
|
|
1437
|
+
if not _secure_review_verification_supported():
|
|
1438
|
+
raise ReviewError(
|
|
1439
|
+
"secure descriptor-relative review verification is unavailable on this platform",
|
|
1440
|
+
code="PLATFORM_REVIEW_VERIFY_UNSUPPORTED",
|
|
1441
|
+
)
|
|
1442
|
+
run_dir = _absolute_lexical_path(run_dir)
|
|
1443
|
+
if not run_dir.name:
|
|
1444
|
+
raise ReviewError(
|
|
1445
|
+
"review run directory must name a non-root directory", code="CANDIDATE_ANCESTOR_INVALID"
|
|
1446
|
+
)
|
|
1447
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
1448
|
+
run_fd = -1
|
|
1449
|
+
monitor: _ReviewAncestorMonitor | None = None
|
|
1450
|
+
try:
|
|
1451
|
+
named = os.stat(run_dir.name, dir_fd=run_parent_fd, follow_symlinks=False)
|
|
1452
|
+
if not stat.S_ISDIR(named.st_mode):
|
|
1453
|
+
raise ReviewError(
|
|
1454
|
+
"review run child is not a directory", code="CANDIDATE_ANCESTOR_INVALID"
|
|
1455
|
+
)
|
|
1456
|
+
run_fd = os.open(run_dir.name, directory_flags, dir_fd=run_parent_fd)
|
|
1457
|
+
opened = os.fstat(run_fd)
|
|
1458
|
+
_require_same_identity(named, opened, "review run child")
|
|
1459
|
+
monitor = _create_review_ancestor_monitor([run_fd])
|
|
1460
|
+
handle = _ReviewRunHandle(
|
|
1461
|
+
run_dir=run_dir,
|
|
1462
|
+
descriptors=[run_fd],
|
|
1463
|
+
records=[
|
|
1464
|
+
(
|
|
1465
|
+
run_fd,
|
|
1466
|
+
run_parent_fd,
|
|
1467
|
+
run_dir.name,
|
|
1468
|
+
_entry_identity(opened),
|
|
1469
|
+
"review run child",
|
|
1470
|
+
)
|
|
1471
|
+
],
|
|
1472
|
+
monitor=monitor,
|
|
1473
|
+
)
|
|
1474
|
+
_validate_review_run_handle(handle)
|
|
1475
|
+
return handle
|
|
1476
|
+
except ReviewError:
|
|
1477
|
+
if monitor is not None:
|
|
1478
|
+
monitor.close()
|
|
1479
|
+
if run_fd >= 0:
|
|
1480
|
+
os.close(run_fd)
|
|
1481
|
+
raise
|
|
1482
|
+
except OSError as exc:
|
|
1483
|
+
if monitor is not None:
|
|
1484
|
+
monitor.close()
|
|
1485
|
+
if run_fd >= 0:
|
|
1486
|
+
os.close(run_fd)
|
|
1487
|
+
raise ReviewError(
|
|
1488
|
+
"review run child cannot be opened safely", code="CANDIDATE_ANCESTOR_INVALID"
|
|
1489
|
+
) from exc
|
|
1490
|
+
|
|
1491
|
+
|
|
1492
|
+
def _create_review_ancestor_monitor(descriptors: list[int]) -> _ReviewAncestorMonitor:
|
|
1493
|
+
"""Watch retained ancestor inodes for move/delete/swap events."""
|
|
1494
|
+
|
|
1495
|
+
if hasattr(select, "kqueue"):
|
|
1496
|
+
queue = select.kqueue()
|
|
1497
|
+
try:
|
|
1498
|
+
flags = select.KQ_NOTE_DELETE | select.KQ_NOTE_RENAME | select.KQ_NOTE_REVOKE
|
|
1499
|
+
if hasattr(select, "KQ_NOTE_ATTRIB"):
|
|
1500
|
+
flags |= select.KQ_NOTE_ATTRIB
|
|
1501
|
+
changes = [
|
|
1502
|
+
select.kevent(
|
|
1503
|
+
descriptor,
|
|
1504
|
+
filter=select.KQ_FILTER_VNODE,
|
|
1505
|
+
flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
|
|
1506
|
+
fflags=flags,
|
|
1507
|
+
)
|
|
1508
|
+
for descriptor in descriptors
|
|
1509
|
+
]
|
|
1510
|
+
queue.control(changes, 0, 0)
|
|
1511
|
+
return _ReviewAncestorMonitor("kqueue", queue, max(1, len(descriptors)))
|
|
1512
|
+
except Exception:
|
|
1513
|
+
queue.close()
|
|
1514
|
+
raise
|
|
1515
|
+
|
|
1516
|
+
if sys.platform.startswith("linux"):
|
|
1517
|
+
monitor_fd = -1
|
|
1518
|
+
try:
|
|
1519
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
1520
|
+
inotify_init1 = libc.inotify_init1
|
|
1521
|
+
inotify_init1.argtypes = [ctypes.c_int]
|
|
1522
|
+
inotify_init1.restype = ctypes.c_int
|
|
1523
|
+
inotify_add_watch = libc.inotify_add_watch
|
|
1524
|
+
inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32]
|
|
1525
|
+
inotify_add_watch.restype = ctypes.c_int
|
|
1526
|
+
monitor_fd = inotify_init1(os.O_CLOEXEC | os.O_NONBLOCK)
|
|
1527
|
+
if monitor_fd < 0:
|
|
1528
|
+
raise OSError(ctypes.get_errno(), "inotify_init1 failed")
|
|
1529
|
+
for descriptor in descriptors:
|
|
1530
|
+
descriptor_path = os.fsencode(f"/proc/self/fd/{descriptor}")
|
|
1531
|
+
if inotify_add_watch(monitor_fd, descriptor_path, _REVIEW_ANCESTOR_WATCH_MASK) < 0:
|
|
1532
|
+
raise OSError(ctypes.get_errno(), "inotify_add_watch failed")
|
|
1533
|
+
return _ReviewAncestorMonitor("inotify", monitor_fd, 1)
|
|
1534
|
+
except (AttributeError, OSError) as exc:
|
|
1535
|
+
if monitor_fd >= 0:
|
|
1536
|
+
os.close(monitor_fd)
|
|
1537
|
+
raise ReviewError(
|
|
1538
|
+
"secure review ancestor monitoring is unavailable on this platform",
|
|
1539
|
+
code="PLATFORM_REVIEW_MONITOR_UNSUPPORTED",
|
|
1540
|
+
) from exc
|
|
1541
|
+
|
|
1542
|
+
raise ReviewError(
|
|
1543
|
+
"secure review ancestor monitoring is unavailable on this platform",
|
|
1544
|
+
code="PLATFORM_REVIEW_MONITOR_UNSUPPORTED",
|
|
1545
|
+
)
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def _validate_review_run_handle(handle: _ReviewRunHandle) -> None:
|
|
1549
|
+
"""Reject ancestor substitution, including kernel-observed swap/restore."""
|
|
1550
|
+
|
|
1551
|
+
if handle.monitor.poll():
|
|
1552
|
+
raise ReviewError(
|
|
1553
|
+
"review run ancestor changed during the transaction", code="CANDIDATE_ANCESTOR_RACE"
|
|
1554
|
+
)
|
|
1555
|
+
try:
|
|
1556
|
+
for descriptor, parent_fd, name, expected, label in handle.records:
|
|
1557
|
+
opened = os.fstat(descriptor)
|
|
1558
|
+
if not stat.S_ISDIR(opened.st_mode) or _entry_identity(opened) != expected:
|
|
1559
|
+
raise ReviewError(
|
|
1560
|
+
f"{label} changed identity during the transaction",
|
|
1561
|
+
code="CANDIDATE_ANCESTOR_RACE",
|
|
1562
|
+
)
|
|
1563
|
+
if parent_fd is not None and name is not None:
|
|
1564
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
1565
|
+
if not stat.S_ISDIR(named.st_mode) or _entry_identity(named) != expected:
|
|
1566
|
+
raise ReviewError(
|
|
1567
|
+
f"{label} path changed during the transaction",
|
|
1568
|
+
code="CANDIDATE_ANCESTOR_RACE",
|
|
1569
|
+
)
|
|
1570
|
+
except ReviewError:
|
|
1571
|
+
raise
|
|
1572
|
+
except OSError as exc:
|
|
1573
|
+
raise ReviewError(
|
|
1574
|
+
"review run ancestor became unavailable", code="CANDIDATE_ANCESTOR_RACE"
|
|
1575
|
+
) from exc
|
|
1576
|
+
if handle.monitor.poll():
|
|
1577
|
+
raise ReviewError(
|
|
1578
|
+
"review run ancestor changed during the transaction", code="CANDIDATE_ANCESTOR_RACE"
|
|
1579
|
+
)
|
|
1580
|
+
|
|
1581
|
+
|
|
1582
|
+
def _acquire_review_publication_lock(
|
|
1583
|
+
handle: _ReviewRunHandle,
|
|
1584
|
+
) -> _ReviewPublicationLock:
|
|
1585
|
+
"""Serialize all finalizers for one run before candidate verification or staging."""
|
|
1586
|
+
|
|
1587
|
+
if fcntl is None: # pragma: no cover - rejected by the platform preflight
|
|
1588
|
+
raise ReviewError(
|
|
1589
|
+
"secure review publication locking is unavailable",
|
|
1590
|
+
code="PLATFORM_REVIEW_LOCK_UNSUPPORTED",
|
|
1591
|
+
)
|
|
1592
|
+
flags = (
|
|
1593
|
+
os.O_RDWR
|
|
1594
|
+
| os.O_CREAT
|
|
1595
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
1596
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
1597
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
1598
|
+
)
|
|
1599
|
+
descriptor = -1
|
|
1600
|
+
try:
|
|
1601
|
+
descriptor = os.open(
|
|
1602
|
+
_REVIEW_PUBLICATION_LOCK,
|
|
1603
|
+
flags,
|
|
1604
|
+
0o600,
|
|
1605
|
+
dir_fd=handle.run_fd,
|
|
1606
|
+
)
|
|
1607
|
+
opened = os.fstat(descriptor)
|
|
1608
|
+
_require_review_publication_lock(opened)
|
|
1609
|
+
named = os.stat(
|
|
1610
|
+
_REVIEW_PUBLICATION_LOCK,
|
|
1611
|
+
dir_fd=handle.run_fd,
|
|
1612
|
+
follow_symlinks=False,
|
|
1613
|
+
)
|
|
1614
|
+
_require_same_identity(opened, named, "review publication lock")
|
|
1615
|
+
|
|
1616
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
1617
|
+
locked = os.fstat(descriptor)
|
|
1618
|
+
_require_same_identity(opened, locked, "review publication lock")
|
|
1619
|
+
_require_review_publication_lock(locked)
|
|
1620
|
+
os.fchmod(descriptor, 0o600)
|
|
1621
|
+
locked = os.fstat(descriptor)
|
|
1622
|
+
_require_review_publication_lock(locked)
|
|
1623
|
+
named = os.stat(
|
|
1624
|
+
_REVIEW_PUBLICATION_LOCK,
|
|
1625
|
+
dir_fd=handle.run_fd,
|
|
1626
|
+
follow_symlinks=False,
|
|
1627
|
+
)
|
|
1628
|
+
_require_same_identity(locked, named, "review publication lock")
|
|
1629
|
+
_require_review_publication_lock(named)
|
|
1630
|
+
publication_lock = _ReviewPublicationLock(
|
|
1631
|
+
handle=handle,
|
|
1632
|
+
descriptor=descriptor,
|
|
1633
|
+
lock_stat=locked,
|
|
1634
|
+
)
|
|
1635
|
+
publication_lock.validate()
|
|
1636
|
+
return publication_lock
|
|
1637
|
+
except ReviewError:
|
|
1638
|
+
if descriptor >= 0:
|
|
1639
|
+
os.close(descriptor)
|
|
1640
|
+
raise
|
|
1641
|
+
except OSError as exc:
|
|
1642
|
+
if descriptor >= 0:
|
|
1643
|
+
os.close(descriptor)
|
|
1644
|
+
raise ReviewError(
|
|
1645
|
+
"review publication lock cannot be acquired safely", code="LOCK_UNSAFE"
|
|
1646
|
+
) from exc
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def _require_review_publication_lock(info: os.stat_result) -> None:
|
|
1650
|
+
expected_uid = os.geteuid() if hasattr(os, "geteuid") else info.st_uid
|
|
1651
|
+
if (
|
|
1652
|
+
not stat.S_ISREG(info.st_mode)
|
|
1653
|
+
or info.st_nlink != 1
|
|
1654
|
+
or info.st_size != 0
|
|
1655
|
+
or info.st_uid != expected_uid
|
|
1656
|
+
or stat.S_IMODE(info.st_mode) != 0o600
|
|
1657
|
+
):
|
|
1658
|
+
raise ReviewError(
|
|
1659
|
+
"review publication lock is not an owned empty single-link mode-0600 regular file",
|
|
1660
|
+
code="LOCK_INVALID",
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def _named_entry_has_identity(
|
|
1665
|
+
parent_fd: int,
|
|
1666
|
+
name: str,
|
|
1667
|
+
expected_identity: tuple[int, int, int] | None,
|
|
1668
|
+
) -> bool:
|
|
1669
|
+
if expected_identity is None:
|
|
1670
|
+
return False
|
|
1671
|
+
try:
|
|
1672
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
1673
|
+
except OSError:
|
|
1674
|
+
return False
|
|
1675
|
+
return _entry_identity(named) == expected_identity
|
|
1676
|
+
|
|
1677
|
+
|
|
1678
|
+
def _review_durability_barrier(descriptor: int, boundary: str) -> None:
|
|
1679
|
+
"""Persist one review directory boundary or fail without deleting publication."""
|
|
1680
|
+
|
|
1681
|
+
try:
|
|
1682
|
+
os.fsync(descriptor)
|
|
1683
|
+
except OSError as exc:
|
|
1684
|
+
raise ReviewError(
|
|
1685
|
+
f"review durability barrier failed: {boundary}", code="ENVELOPE_DURABILITY_FAILED"
|
|
1686
|
+
) from exc
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def _ensure_installed_review_durable(
|
|
1690
|
+
handle: _ReviewRunHandle,
|
|
1691
|
+
*,
|
|
1692
|
+
retained_review_fd: int | None = None,
|
|
1693
|
+
) -> None:
|
|
1694
|
+
"""Retryable post-rename durability barriers for an installed review."""
|
|
1695
|
+
|
|
1696
|
+
owned = retained_review_fd is None
|
|
1697
|
+
review_fd = retained_review_fd if retained_review_fd is not None else -1
|
|
1698
|
+
try:
|
|
1699
|
+
review_named = os.stat("review", dir_fd=handle.run_fd, follow_symlinks=False)
|
|
1700
|
+
_require_sealed_directory(review_named, "review root")
|
|
1701
|
+
if review_fd < 0:
|
|
1702
|
+
review_fd = os.open(
|
|
1703
|
+
"review",
|
|
1704
|
+
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
1705
|
+
dir_fd=handle.run_fd,
|
|
1706
|
+
)
|
|
1707
|
+
review_opened = os.fstat(review_fd)
|
|
1708
|
+
_require_same_identity(review_named, review_opened, "review root")
|
|
1709
|
+
_require_sealed_directory(review_opened, "review root")
|
|
1710
|
+
_review_durability_barrier(review_fd, "review-root")
|
|
1711
|
+
_review_durability_barrier(handle.run_fd, "review-run-parent")
|
|
1712
|
+
review_after = os.stat("review", dir_fd=handle.run_fd, follow_symlinks=False)
|
|
1713
|
+
_require_stable_stat(review_opened, review_after, "review root")
|
|
1714
|
+
except ReviewError:
|
|
1715
|
+
raise
|
|
1716
|
+
except OSError as exc:
|
|
1717
|
+
raise ReviewError(
|
|
1718
|
+
"installed review durability cannot be established safely",
|
|
1719
|
+
code="ENVELOPE_DURABILITY_FAILED",
|
|
1720
|
+
) from exc
|
|
1721
|
+
finally:
|
|
1722
|
+
if owned and review_fd >= 0:
|
|
1723
|
+
os.close(review_fd)
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def _stage_review_envelope(
|
|
1727
|
+
root_fd: int,
|
|
1728
|
+
*,
|
|
1729
|
+
parent_fd: int,
|
|
1730
|
+
staging_name: str,
|
|
1731
|
+
expected_root_identity: tuple[int, int, int],
|
|
1732
|
+
assignment: dict[str, Any],
|
|
1733
|
+
reports_by_lens: dict[str, dict[str, Any]],
|
|
1734
|
+
findings: list[dict[str, Any]],
|
|
1735
|
+
backlog: list[dict[str, Any]],
|
|
1736
|
+
status: str,
|
|
1737
|
+
verified_candidate: Any,
|
|
1738
|
+
verifier_private_key: TrustedSigner,
|
|
1739
|
+
) -> str:
|
|
1740
|
+
"""Write, hash, and seal the exact retained staging descriptors."""
|
|
1741
|
+
|
|
1742
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
1743
|
+
reports_fd = -1
|
|
1744
|
+
# Member label -> (parent descriptor, name, open descriptor, sealed stat).
|
|
1745
|
+
retained: dict[str, tuple[int, str, int, os.stat_result]] = {}
|
|
1746
|
+
try:
|
|
1747
|
+
root_opened = os.fstat(root_fd)
|
|
1748
|
+
if _entry_identity(root_opened) != expected_root_identity:
|
|
1749
|
+
raise ReviewError(
|
|
1750
|
+
"review staging root changed identity before writing",
|
|
1751
|
+
code="ENVELOPE_STAGING_ROOT_CHANGED",
|
|
1752
|
+
)
|
|
1753
|
+
if set(os.listdir(root_fd)):
|
|
1754
|
+
raise ReviewError(
|
|
1755
|
+
"review staging root was not empty before writing",
|
|
1756
|
+
code="ENVELOPE_STAGING_ROOT_NOT_EMPTY",
|
|
1757
|
+
)
|
|
1758
|
+
|
|
1759
|
+
os.mkdir("reports", mode=0o700, dir_fd=root_fd)
|
|
1760
|
+
reports_named = os.stat("reports", dir_fd=root_fd, follow_symlinks=False)
|
|
1761
|
+
if not stat.S_ISDIR(reports_named.st_mode):
|
|
1762
|
+
raise ReviewError(
|
|
1763
|
+
"review reports member is not a directory", code="ENVELOPE_REPORTS_MEMBER_INVALID"
|
|
1764
|
+
)
|
|
1765
|
+
reports_fd = os.open("reports", directory_flags, dir_fd=root_fd)
|
|
1766
|
+
reports_opened = os.fstat(reports_fd)
|
|
1767
|
+
_require_same_identity(reports_named, reports_opened, "review reports directory")
|
|
1768
|
+
|
|
1769
|
+
assignment_fd, assignment_stat, assignment_sha256 = _create_hash_and_seal_member(
|
|
1770
|
+
root_fd,
|
|
1771
|
+
"assignment.json",
|
|
1772
|
+
"assignment.json",
|
|
1773
|
+
canonical_json_line_bytes(assignment),
|
|
1774
|
+
)
|
|
1775
|
+
retained["assignment.json"] = (
|
|
1776
|
+
root_fd,
|
|
1777
|
+
"assignment.json",
|
|
1778
|
+
assignment_fd,
|
|
1779
|
+
assignment_stat,
|
|
1780
|
+
)
|
|
1781
|
+
report_sha256s: dict[str, str] = {}
|
|
1782
|
+
for lens in sorted(reports_by_lens):
|
|
1783
|
+
name = f"{lens}.json"
|
|
1784
|
+
descriptor, sealed, digest = _create_hash_and_seal_member(
|
|
1785
|
+
reports_fd,
|
|
1786
|
+
name,
|
|
1787
|
+
f"reports/{name}",
|
|
1788
|
+
canonical_json_line_bytes(reports_by_lens[lens]),
|
|
1789
|
+
)
|
|
1790
|
+
retained[f"reports/{name}"] = (reports_fd, name, descriptor, sealed)
|
|
1791
|
+
report_sha256s[lens] = digest
|
|
1792
|
+
|
|
1793
|
+
findings_fd, findings_stat, findings_sha256 = _create_hash_and_seal_member(
|
|
1794
|
+
root_fd,
|
|
1795
|
+
"findings.json",
|
|
1796
|
+
"findings.json",
|
|
1797
|
+
canonical_json_line_bytes(findings),
|
|
1798
|
+
)
|
|
1799
|
+
retained["findings.json"] = (
|
|
1800
|
+
root_fd,
|
|
1801
|
+
"findings.json",
|
|
1802
|
+
findings_fd,
|
|
1803
|
+
findings_stat,
|
|
1804
|
+
)
|
|
1805
|
+
backlog_fd, backlog_stat, backlog_sha256 = _create_hash_and_seal_member(
|
|
1806
|
+
root_fd,
|
|
1807
|
+
"backlog.json",
|
|
1808
|
+
"backlog.json",
|
|
1809
|
+
canonical_json_line_bytes(backlog),
|
|
1810
|
+
)
|
|
1811
|
+
retained["backlog.json"] = (
|
|
1812
|
+
root_fd,
|
|
1813
|
+
"backlog.json",
|
|
1814
|
+
backlog_fd,
|
|
1815
|
+
backlog_stat,
|
|
1816
|
+
)
|
|
1817
|
+
|
|
1818
|
+
decision, decision_digest = _build_signed_decision(
|
|
1819
|
+
assignment=assignment,
|
|
1820
|
+
status=status,
|
|
1821
|
+
verified_candidate=verified_candidate,
|
|
1822
|
+
verifier_private_key=verifier_private_key,
|
|
1823
|
+
assignment_sha256=assignment_sha256,
|
|
1824
|
+
report_sha256s=report_sha256s,
|
|
1825
|
+
findings_sha256=findings_sha256,
|
|
1826
|
+
backlog_sha256=backlog_sha256,
|
|
1827
|
+
)
|
|
1828
|
+
decision_fd, decision_stat, _ = _create_hash_and_seal_member(
|
|
1829
|
+
root_fd,
|
|
1830
|
+
"decision.json",
|
|
1831
|
+
"decision.json",
|
|
1832
|
+
canonical_json_line_bytes(decision),
|
|
1833
|
+
)
|
|
1834
|
+
retained["decision.json"] = (
|
|
1835
|
+
root_fd,
|
|
1836
|
+
"decision.json",
|
|
1837
|
+
decision_fd,
|
|
1838
|
+
decision_stat,
|
|
1839
|
+
)
|
|
1840
|
+
|
|
1841
|
+
os.fchmod(reports_fd, 0o555)
|
|
1842
|
+
os.fsync(reports_fd)
|
|
1843
|
+
reports_sealed = os.fstat(reports_fd)
|
|
1844
|
+
_require_sealed_directory(reports_sealed, "review reports directory")
|
|
1845
|
+
_validate_staged_quiescence(
|
|
1846
|
+
parent_fd=parent_fd,
|
|
1847
|
+
staging_name=staging_name,
|
|
1848
|
+
expected_root_identity=expected_root_identity,
|
|
1849
|
+
root_fd=root_fd,
|
|
1850
|
+
reports_fd=reports_fd,
|
|
1851
|
+
reports_sealed=reports_sealed,
|
|
1852
|
+
retained=retained,
|
|
1853
|
+
)
|
|
1854
|
+
return decision_digest
|
|
1855
|
+
except ReviewError:
|
|
1856
|
+
raise
|
|
1857
|
+
except OSError as exc:
|
|
1858
|
+
raise ReviewError(
|
|
1859
|
+
"review staging envelope cannot be created safely", code="ENVELOPE_STAGING_UNSAFE"
|
|
1860
|
+
) from exc
|
|
1861
|
+
finally:
|
|
1862
|
+
for _, _, descriptor, _ in retained.values():
|
|
1863
|
+
os.close(descriptor)
|
|
1864
|
+
if reports_fd >= 0:
|
|
1865
|
+
os.close(reports_fd)
|
|
1866
|
+
|
|
1867
|
+
|
|
1868
|
+
def _create_hash_and_seal_member(
|
|
1869
|
+
parent_fd: int,
|
|
1870
|
+
name: str,
|
|
1871
|
+
label: str,
|
|
1872
|
+
raw: bytes,
|
|
1873
|
+
) -> tuple[int, os.stat_result, str]:
|
|
1874
|
+
if len(raw) > _MAX_REVIEW_MEMBER_BYTES:
|
|
1875
|
+
raise ReviewError(f"review member is too large: {label}", code="ENVELOPE_MEMBER_TOO_LARGE")
|
|
1876
|
+
flags = (
|
|
1877
|
+
os.O_RDWR
|
|
1878
|
+
| os.O_CREAT
|
|
1879
|
+
| os.O_EXCL
|
|
1880
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
1881
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
1882
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
1883
|
+
)
|
|
1884
|
+
descriptor = os.open(name, flags, 0o600, dir_fd=parent_fd)
|
|
1885
|
+
try:
|
|
1886
|
+
opened = os.fstat(descriptor)
|
|
1887
|
+
if not stat.S_ISREG(opened.st_mode) or opened.st_nlink != 1:
|
|
1888
|
+
raise ReviewError(
|
|
1889
|
+
f"review staging member is not a single-link regular file: {label}",
|
|
1890
|
+
code="ENVELOPE_STAGING_MEMBER_INVALID",
|
|
1891
|
+
)
|
|
1892
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
1893
|
+
_require_same_identity(opened, named, label)
|
|
1894
|
+
|
|
1895
|
+
offset = 0
|
|
1896
|
+
while offset < len(raw):
|
|
1897
|
+
written = os.write(descriptor, raw[offset:])
|
|
1898
|
+
if written <= 0:
|
|
1899
|
+
raise ReviewError(
|
|
1900
|
+
f"review staging member could not be written: {label}",
|
|
1901
|
+
code="ENVELOPE_STAGING_MEMBER_UNWRITABLE",
|
|
1902
|
+
)
|
|
1903
|
+
offset += written
|
|
1904
|
+
os.fsync(descriptor)
|
|
1905
|
+
written_stat = os.fstat(descriptor)
|
|
1906
|
+
if written_stat.st_size != len(raw):
|
|
1907
|
+
raise ReviewError(
|
|
1908
|
+
f"review staging member size changed while writing: {label}",
|
|
1909
|
+
code="ENVELOPE_STAGING_MEMBER_CHANGED",
|
|
1910
|
+
)
|
|
1911
|
+
os.lseek(descriptor, 0, os.SEEK_SET)
|
|
1912
|
+
observed = _read_bounded_descriptor(descriptor, label)
|
|
1913
|
+
_require_stable_stat(written_stat, os.fstat(descriptor), label)
|
|
1914
|
+
if observed != raw:
|
|
1915
|
+
raise ReviewError(
|
|
1916
|
+
f"review staging member changed before hashing: {label}",
|
|
1917
|
+
code="ENVELOPE_STAGING_MEMBER_CHANGED",
|
|
1918
|
+
)
|
|
1919
|
+
digest = sha256_bytes(observed)
|
|
1920
|
+
|
|
1921
|
+
os.fchmod(descriptor, 0o444)
|
|
1922
|
+
os.fsync(descriptor)
|
|
1923
|
+
sealed = os.fstat(descriptor)
|
|
1924
|
+
_require_sealed_file(sealed, label)
|
|
1925
|
+
named_after = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
1926
|
+
_require_stable_stat(sealed, named_after, label)
|
|
1927
|
+
return descriptor, sealed, digest
|
|
1928
|
+
except Exception:
|
|
1929
|
+
os.close(descriptor)
|
|
1930
|
+
raise
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def _read_bounded_descriptor(descriptor: int, label: str) -> bytes:
|
|
1934
|
+
raw = bytearray()
|
|
1935
|
+
while True:
|
|
1936
|
+
remaining = _MAX_REVIEW_MEMBER_BYTES + 1 - len(raw)
|
|
1937
|
+
chunk = os.read(descriptor, min(1024 * 1024, remaining))
|
|
1938
|
+
if not chunk:
|
|
1939
|
+
return bytes(raw)
|
|
1940
|
+
raw.extend(chunk)
|
|
1941
|
+
if len(raw) > _MAX_REVIEW_MEMBER_BYTES:
|
|
1942
|
+
raise ReviewError(
|
|
1943
|
+
f"review member is too large: {label}", code="ENVELOPE_MEMBER_TOO_LARGE"
|
|
1944
|
+
)
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def _build_signed_decision(
|
|
1948
|
+
*,
|
|
1949
|
+
assignment: dict[str, Any],
|
|
1950
|
+
status: str,
|
|
1951
|
+
verified_candidate: Any,
|
|
1952
|
+
verifier_private_key: TrustedSigner,
|
|
1953
|
+
assignment_sha256: str,
|
|
1954
|
+
report_sha256s: dict[str, str],
|
|
1955
|
+
findings_sha256: str,
|
|
1956
|
+
backlog_sha256: str,
|
|
1957
|
+
) -> tuple[dict[str, Any], str]:
|
|
1958
|
+
decision_payload = _decision_payload(
|
|
1959
|
+
assignment=assignment,
|
|
1960
|
+
status=status,
|
|
1961
|
+
verified_candidate=verified_candidate,
|
|
1962
|
+
assignment_sha256=assignment_sha256,
|
|
1963
|
+
report_sha256s=report_sha256s,
|
|
1964
|
+
findings_sha256=findings_sha256,
|
|
1965
|
+
backlog_sha256=backlog_sha256,
|
|
1966
|
+
)
|
|
1967
|
+
decision_digest = sha256_bytes(canonical_json_line_bytes(decision_payload))
|
|
1968
|
+
signed_body = {**decision_payload, "decision_digest": decision_digest}
|
|
1969
|
+
signature = verifier_private_key.sign(canonical_json_line_bytes(signed_body))
|
|
1970
|
+
return (
|
|
1971
|
+
{
|
|
1972
|
+
**signed_body,
|
|
1973
|
+
"signature": base64.b64encode(signature).decode("ascii"),
|
|
1974
|
+
},
|
|
1975
|
+
decision_digest,
|
|
1976
|
+
)
|
|
1977
|
+
|
|
1978
|
+
|
|
1979
|
+
def _decision_payload(
|
|
1980
|
+
*,
|
|
1981
|
+
assignment: dict[str, Any],
|
|
1982
|
+
status: str,
|
|
1983
|
+
verified_candidate: Any,
|
|
1984
|
+
assignment_sha256: str,
|
|
1985
|
+
report_sha256s: dict[str, str],
|
|
1986
|
+
findings_sha256: str,
|
|
1987
|
+
backlog_sha256: str,
|
|
1988
|
+
) -> dict[str, Any]:
|
|
1989
|
+
return {
|
|
1990
|
+
"schema_version": DECISION_SCHEMA_VERSION,
|
|
1991
|
+
"candidate_digest": verified_candidate.candidate_digest,
|
|
1992
|
+
"table_sha256": verified_candidate.table_sha256,
|
|
1993
|
+
"validation_policy_digest": verified_candidate.validation_policy_digest,
|
|
1994
|
+
"assignment_sha256": assignment_sha256,
|
|
1995
|
+
"report_sha256s": report_sha256s,
|
|
1996
|
+
"findings_sha256": findings_sha256,
|
|
1997
|
+
"backlog_sha256": backlog_sha256,
|
|
1998
|
+
"cycle_lineage": assignment["cycle_lineage"],
|
|
1999
|
+
"failed_cycle_count": assignment["failed_cycle_count"],
|
|
2000
|
+
"verifier_version": VERIFIER_VERSION,
|
|
2001
|
+
"verifier_principal": assignment["verifier"]["principal_id"],
|
|
2002
|
+
"status": status,
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
|
|
2006
|
+
def _expected_review_decision_digest(
|
|
2007
|
+
*,
|
|
2008
|
+
assignment: dict[str, Any],
|
|
2009
|
+
reports_by_lens: dict[str, dict[str, Any]],
|
|
2010
|
+
findings: list[dict[str, Any]],
|
|
2011
|
+
backlog: list[dict[str, Any]],
|
|
2012
|
+
status: str,
|
|
2013
|
+
verified_candidate: Any,
|
|
2014
|
+
) -> str:
|
|
2015
|
+
payload = _decision_payload(
|
|
2016
|
+
assignment=assignment,
|
|
2017
|
+
status=status,
|
|
2018
|
+
verified_candidate=verified_candidate,
|
|
2019
|
+
assignment_sha256=sha256_bytes(canonical_json_line_bytes(assignment)),
|
|
2020
|
+
report_sha256s={
|
|
2021
|
+
lens: sha256_bytes(canonical_json_line_bytes(reports_by_lens[lens]))
|
|
2022
|
+
for lens in sorted(REQUIRED_LENSES)
|
|
2023
|
+
},
|
|
2024
|
+
findings_sha256=sha256_bytes(canonical_json_line_bytes(findings)),
|
|
2025
|
+
backlog_sha256=sha256_bytes(canonical_json_line_bytes(backlog)),
|
|
2026
|
+
)
|
|
2027
|
+
return sha256_bytes(canonical_json_line_bytes(payload))
|
|
2028
|
+
|
|
2029
|
+
|
|
2030
|
+
def _validate_staged_quiescence(
|
|
2031
|
+
*,
|
|
2032
|
+
parent_fd: int,
|
|
2033
|
+
staging_name: str,
|
|
2034
|
+
expected_root_identity: tuple[int, int, int],
|
|
2035
|
+
root_fd: int,
|
|
2036
|
+
reports_fd: int,
|
|
2037
|
+
reports_sealed: os.stat_result,
|
|
2038
|
+
retained: dict[str, tuple[int, str, int, os.stat_result]],
|
|
2039
|
+
) -> None:
|
|
2040
|
+
root_before = os.fstat(root_fd)
|
|
2041
|
+
if _entry_identity(root_before) != expected_root_identity:
|
|
2042
|
+
raise ReviewError(
|
|
2043
|
+
"review staging root changed identity before install",
|
|
2044
|
+
code="ENVELOPE_STAGING_ROOT_CHANGED",
|
|
2045
|
+
)
|
|
2046
|
+
if not stat.S_ISDIR(root_before.st_mode) or stat.S_IMODE(root_before.st_mode) != 0o700:
|
|
2047
|
+
raise ReviewError(
|
|
2048
|
+
"review staging root is not mode-0700 before install", code="ENVELOPE_STAGING_ROOT_MODE"
|
|
2049
|
+
)
|
|
2050
|
+
if set(os.listdir(root_fd)) != _REVIEW_ROOT_MEMBERS:
|
|
2051
|
+
raise ReviewError(
|
|
2052
|
+
"review staging member set is invalid", code="ENVELOPE_STAGING_MEMBERS_INVALID"
|
|
2053
|
+
)
|
|
2054
|
+
if set(os.listdir(reports_fd)) != _REVIEW_REPORT_MEMBERS:
|
|
2055
|
+
raise ReviewError(
|
|
2056
|
+
"review staging report member set is invalid",
|
|
2057
|
+
code="ENVELOPE_STAGING_REPORT_MEMBERS_INVALID",
|
|
2058
|
+
)
|
|
2059
|
+
reports_named = os.stat("reports", dir_fd=root_fd, follow_symlinks=False)
|
|
2060
|
+
_require_stable_stat(reports_sealed, reports_named, "review reports directory")
|
|
2061
|
+
|
|
2062
|
+
for label in sorted(retained):
|
|
2063
|
+
member_parent_fd, name, retained_fd, sealed = retained[label]
|
|
2064
|
+
retained_now = os.fstat(retained_fd)
|
|
2065
|
+
_require_stable_stat(sealed, retained_now, label)
|
|
2066
|
+
_require_sealed_file(retained_now, label)
|
|
2067
|
+
named = os.stat(name, dir_fd=member_parent_fd, follow_symlinks=False)
|
|
2068
|
+
_require_stable_stat(sealed, named, label)
|
|
2069
|
+
_require_sealed_file(named, label)
|
|
2070
|
+
flags = (
|
|
2071
|
+
os.O_RDONLY
|
|
2072
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
2073
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
2074
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
2075
|
+
)
|
|
2076
|
+
check_fd = os.open(name, flags, dir_fd=member_parent_fd)
|
|
2077
|
+
try:
|
|
2078
|
+
checked = os.fstat(check_fd)
|
|
2079
|
+
_require_stable_stat(sealed, checked, label)
|
|
2080
|
+
_require_sealed_file(checked, label)
|
|
2081
|
+
finally:
|
|
2082
|
+
os.close(check_fd)
|
|
2083
|
+
named_after = os.stat(name, dir_fd=member_parent_fd, follow_symlinks=False)
|
|
2084
|
+
_require_stable_stat(sealed, named_after, label)
|
|
2085
|
+
|
|
2086
|
+
if set(os.listdir(reports_fd)) != _REVIEW_REPORT_MEMBERS:
|
|
2087
|
+
raise ReviewError(
|
|
2088
|
+
"review staging report member set changed before install",
|
|
2089
|
+
code="ENVELOPE_STAGING_REPORT_MEMBERS_CHANGED",
|
|
2090
|
+
)
|
|
2091
|
+
_require_stable_stat(reports_sealed, os.fstat(reports_fd), "review reports directory")
|
|
2092
|
+
if set(os.listdir(root_fd)) != _REVIEW_ROOT_MEMBERS:
|
|
2093
|
+
raise ReviewError(
|
|
2094
|
+
"review staging member set changed before install",
|
|
2095
|
+
code="ENVELOPE_STAGING_MEMBERS_CHANGED",
|
|
2096
|
+
)
|
|
2097
|
+
_require_stable_stat(root_before, os.fstat(root_fd), "review staging root")
|
|
2098
|
+
root_named = os.stat(
|
|
2099
|
+
staging_name,
|
|
2100
|
+
dir_fd=parent_fd,
|
|
2101
|
+
follow_symlinks=False,
|
|
2102
|
+
)
|
|
2103
|
+
_require_same_identity(root_before, root_named, "review staging root")
|
|
2104
|
+
|
|
2105
|
+
|
|
2106
|
+
def _read_sealed_review_tree(
|
|
2107
|
+
handle: _ReviewRunHandle,
|
|
2108
|
+
*,
|
|
2109
|
+
retained_review_fd: int | None,
|
|
2110
|
+
expected_review_identity: tuple[int, int, int] | None,
|
|
2111
|
+
) -> tuple[dict[str, bytes], _ReviewSnapshotLease]:
|
|
2112
|
+
"""Read one exact review snapshot without following any member path."""
|
|
2113
|
+
|
|
2114
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
2115
|
+
review_fd = -1
|
|
2116
|
+
reports_fd = -1
|
|
2117
|
+
owned_review_fd = retained_review_fd is None
|
|
2118
|
+
success = False
|
|
2119
|
+
try:
|
|
2120
|
+
_validate_review_run_handle(handle)
|
|
2121
|
+
review_named = os.stat("review", dir_fd=handle.run_fd, follow_symlinks=False)
|
|
2122
|
+
_require_sealed_directory(review_named, "review root")
|
|
2123
|
+
if expected_review_identity is not None and (
|
|
2124
|
+
_entry_identity(review_named) != expected_review_identity
|
|
2125
|
+
):
|
|
2126
|
+
raise ReviewError(
|
|
2127
|
+
"installed review root does not match the finalized staging root",
|
|
2128
|
+
code="ENVELOPE_INSTALL_ROOT_MISMATCH",
|
|
2129
|
+
)
|
|
2130
|
+
review_fd = (
|
|
2131
|
+
os.open("review", directory_flags, dir_fd=handle.run_fd)
|
|
2132
|
+
if retained_review_fd is None
|
|
2133
|
+
else retained_review_fd
|
|
2134
|
+
)
|
|
2135
|
+
review_opened = os.fstat(review_fd)
|
|
2136
|
+
_require_same_identity(review_named, review_opened, "review root")
|
|
2137
|
+
_require_sealed_directory(review_opened, "review root")
|
|
2138
|
+
if set(os.listdir(review_fd)) != _REVIEW_ROOT_MEMBERS:
|
|
2139
|
+
raise ReviewError(
|
|
2140
|
+
"review envelope member set has changed", code="ENVELOPE_MEMBERS_CHANGED"
|
|
2141
|
+
)
|
|
2142
|
+
|
|
2143
|
+
reports_named = os.stat("reports", dir_fd=review_fd, follow_symlinks=False)
|
|
2144
|
+
_require_sealed_directory(reports_named, "review reports directory")
|
|
2145
|
+
reports_fd = os.open("reports", directory_flags, dir_fd=review_fd)
|
|
2146
|
+
reports_opened = os.fstat(reports_fd)
|
|
2147
|
+
_require_same_identity(reports_named, reports_opened, "review reports directory")
|
|
2148
|
+
if set(os.listdir(reports_fd)) != _REVIEW_REPORT_MEMBERS:
|
|
2149
|
+
raise ReviewError(
|
|
2150
|
+
"review report member set has changed", code="ENVELOPE_REPORT_MEMBERS_CHANGED"
|
|
2151
|
+
)
|
|
2152
|
+
|
|
2153
|
+
envelope = {
|
|
2154
|
+
name: _read_sealed_member(review_fd, name, name)
|
|
2155
|
+
for name in sorted(_REVIEW_ROOT_MEMBERS - {"reports"})
|
|
2156
|
+
}
|
|
2157
|
+
envelope.update(
|
|
2158
|
+
{
|
|
2159
|
+
f"reports/{name}": _read_sealed_member(
|
|
2160
|
+
reports_fd,
|
|
2161
|
+
name,
|
|
2162
|
+
f"reports/{name}",
|
|
2163
|
+
)
|
|
2164
|
+
for name in sorted(_REVIEW_REPORT_MEMBERS)
|
|
2165
|
+
}
|
|
2166
|
+
)
|
|
2167
|
+
if set(envelope) != _REVIEW_FILE_PATHS:
|
|
2168
|
+
raise ReviewError(
|
|
2169
|
+
"review envelope member set has changed", code="ENVELOPE_MEMBERS_CHANGED"
|
|
2170
|
+
)
|
|
2171
|
+
if set(os.listdir(reports_fd)) != _REVIEW_REPORT_MEMBERS:
|
|
2172
|
+
raise ReviewError(
|
|
2173
|
+
"review report member set changed during verification",
|
|
2174
|
+
code="ENVELOPE_REPORT_MEMBERS_CHANGED",
|
|
2175
|
+
)
|
|
2176
|
+
_require_stable_stat(reports_opened, os.fstat(reports_fd), "review reports directory")
|
|
2177
|
+
reports_after = os.stat("reports", dir_fd=review_fd, follow_symlinks=False)
|
|
2178
|
+
_require_stable_stat(reports_named, reports_after, "review reports directory")
|
|
2179
|
+
if set(os.listdir(review_fd)) != _REVIEW_ROOT_MEMBERS:
|
|
2180
|
+
raise ReviewError(
|
|
2181
|
+
"review envelope member set changed during verification",
|
|
2182
|
+
code="ENVELOPE_MEMBERS_CHANGED",
|
|
2183
|
+
)
|
|
2184
|
+
_require_stable_stat(review_opened, os.fstat(review_fd), "review root")
|
|
2185
|
+
review_after = os.stat("review", dir_fd=handle.run_fd, follow_symlinks=False)
|
|
2186
|
+
_require_stable_stat(review_named, review_after, "review root")
|
|
2187
|
+
_validate_review_run_handle(handle)
|
|
2188
|
+
lease = _ReviewSnapshotLease(
|
|
2189
|
+
handle=handle,
|
|
2190
|
+
review_fd=review_fd,
|
|
2191
|
+
review_stat=review_named,
|
|
2192
|
+
owns_review_fd=owned_review_fd,
|
|
2193
|
+
)
|
|
2194
|
+
lease.validate()
|
|
2195
|
+
success = True
|
|
2196
|
+
return envelope, lease
|
|
2197
|
+
except ReviewError:
|
|
2198
|
+
raise
|
|
2199
|
+
except OSError as exc:
|
|
2200
|
+
raise ReviewError(
|
|
2201
|
+
"sealed review envelope cannot be read safely", code="ENVELOPE_UNREADABLE"
|
|
2202
|
+
) from exc
|
|
2203
|
+
finally:
|
|
2204
|
+
if reports_fd >= 0:
|
|
2205
|
+
os.close(reports_fd)
|
|
2206
|
+
if not success and owned_review_fd and review_fd >= 0:
|
|
2207
|
+
os.close(review_fd)
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
def _read_sealed_member(parent_fd: int, name: str, label: str) -> bytes:
|
|
2211
|
+
named = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
2212
|
+
_require_sealed_file(named, label)
|
|
2213
|
+
flags = (
|
|
2214
|
+
os.O_RDONLY
|
|
2215
|
+
| getattr(os, "O_NOFOLLOW", 0)
|
|
2216
|
+
| getattr(os, "O_CLOEXEC", 0)
|
|
2217
|
+
| getattr(os, "O_NONBLOCK", 0)
|
|
2218
|
+
)
|
|
2219
|
+
descriptor = os.open(name, flags, dir_fd=parent_fd)
|
|
2220
|
+
try:
|
|
2221
|
+
opened = os.fstat(descriptor)
|
|
2222
|
+
_require_sealed_file(opened, label)
|
|
2223
|
+
_require_same_identity(named, opened, label)
|
|
2224
|
+
raw = bytearray()
|
|
2225
|
+
while True:
|
|
2226
|
+
remaining = _MAX_REVIEW_MEMBER_BYTES + 1 - len(raw)
|
|
2227
|
+
chunk = os.read(descriptor, min(1024 * 1024, remaining))
|
|
2228
|
+
if not chunk:
|
|
2229
|
+
break
|
|
2230
|
+
raw.extend(chunk)
|
|
2231
|
+
if len(raw) > _MAX_REVIEW_MEMBER_BYTES:
|
|
2232
|
+
raise ReviewError(
|
|
2233
|
+
f"review member is too large: {label}", code="ENVELOPE_MEMBER_TOO_LARGE"
|
|
2234
|
+
)
|
|
2235
|
+
_require_stable_stat(opened, os.fstat(descriptor), label)
|
|
2236
|
+
finally:
|
|
2237
|
+
os.close(descriptor)
|
|
2238
|
+
after = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
2239
|
+
_require_stable_stat(named, after, label)
|
|
2240
|
+
return bytes(raw)
|
|
2241
|
+
|
|
2242
|
+
|
|
2243
|
+
def _require_sealed_directory(info: os.stat_result, label: str) -> None:
|
|
2244
|
+
if not stat.S_ISDIR(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o555:
|
|
2245
|
+
raise ReviewError(
|
|
2246
|
+
f"{label} is not a sealed mode-0555 directory", code="ENVELOPE_MEMBER_NOT_SEALED"
|
|
2247
|
+
)
|
|
2248
|
+
|
|
2249
|
+
|
|
2250
|
+
def _require_sealed_file(info: os.stat_result, label: str) -> None:
|
|
2251
|
+
if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or stat.S_IMODE(info.st_mode) != 0o444:
|
|
2252
|
+
raise ReviewError(
|
|
2253
|
+
f"{label} is not a sealed single-link mode-0444 regular file",
|
|
2254
|
+
code="ENVELOPE_MEMBER_NOT_SEALED",
|
|
2255
|
+
)
|
|
2256
|
+
|
|
2257
|
+
|
|
2258
|
+
def _stat_signature(info: os.stat_result) -> tuple[int, ...]:
|
|
2259
|
+
return (
|
|
2260
|
+
info.st_dev,
|
|
2261
|
+
info.st_ino,
|
|
2262
|
+
info.st_mode,
|
|
2263
|
+
info.st_nlink,
|
|
2264
|
+
info.st_size,
|
|
2265
|
+
info.st_mtime_ns,
|
|
2266
|
+
info.st_ctime_ns,
|
|
2267
|
+
)
|
|
2268
|
+
|
|
2269
|
+
|
|
2270
|
+
def _entry_identity(info: os.stat_result) -> tuple[int, int, int]:
|
|
2271
|
+
return (info.st_dev, info.st_ino, stat.S_IFMT(info.st_mode))
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def _require_same_identity(before: os.stat_result, after: os.stat_result, label: str) -> None:
|
|
2275
|
+
if _entry_identity(before) != _entry_identity(after):
|
|
2276
|
+
raise ReviewError(
|
|
2277
|
+
f"{label} changed identity during verification", code="ENVELOPE_MEMBER_CHANGED"
|
|
2278
|
+
)
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def _require_stable_stat(before: os.stat_result, after: os.stat_result, label: str) -> None:
|
|
2282
|
+
if _stat_signature(before) != _stat_signature(after):
|
|
2283
|
+
raise ReviewError(f"{label} changed during verification", code="ENVELOPE_MEMBER_CHANGED")
|
|
2284
|
+
|
|
2285
|
+
|
|
2286
|
+
def _rename_no_replace(
|
|
2287
|
+
source: Path,
|
|
2288
|
+
destination: Path,
|
|
2289
|
+
*,
|
|
2290
|
+
expected_source_identity: tuple[int, int, int] | None = None,
|
|
2291
|
+
installed_mode: int | None = None,
|
|
2292
|
+
parent_fd: int | None = None,
|
|
2293
|
+
source_fd: int | None = None,
|
|
2294
|
+
) -> None:
|
|
2295
|
+
"""Atomically install a directory while refusing to replace any destination."""
|
|
2296
|
+
|
|
2297
|
+
if source.parent != destination.parent:
|
|
2298
|
+
raise OSError(errno.EXDEV, "review install must remain within one directory")
|
|
2299
|
+
if os.name == "nt":
|
|
2300
|
+
raise OSError(
|
|
2301
|
+
errno.ENOSYS,
|
|
2302
|
+
"secure descriptor-relative review install is unavailable on Windows",
|
|
2303
|
+
)
|
|
2304
|
+
|
|
2305
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
2306
|
+
active_parent_fd = parent_fd if parent_fd is not None else -1
|
|
2307
|
+
active_source_fd = source_fd if source_fd is not None else -1
|
|
2308
|
+
owned_parent_fd = parent_fd is None
|
|
2309
|
+
owned_source_fd = source_fd is None
|
|
2310
|
+
try:
|
|
2311
|
+
if active_parent_fd < 0:
|
|
2312
|
+
active_parent_fd = os.open(source.parent, flags)
|
|
2313
|
+
source_named = os.stat(
|
|
2314
|
+
source.name,
|
|
2315
|
+
dir_fd=active_parent_fd,
|
|
2316
|
+
follow_symlinks=False,
|
|
2317
|
+
)
|
|
2318
|
+
expected = expected_source_identity or _entry_identity(source_named)
|
|
2319
|
+
if _entry_identity(source_named) != expected or not stat.S_ISDIR(source_named.st_mode):
|
|
2320
|
+
raise OSError(errno.ESTALE, "review install source identity changed", source)
|
|
2321
|
+
if active_source_fd < 0:
|
|
2322
|
+
active_source_fd = os.open(source.name, flags, dir_fd=active_parent_fd)
|
|
2323
|
+
source_opened = os.fstat(active_source_fd)
|
|
2324
|
+
if _entry_identity(source_opened) != expected:
|
|
2325
|
+
raise OSError(errno.ESTALE, "review install source identity changed", source)
|
|
2326
|
+
|
|
2327
|
+
if sys.platform.startswith("linux"):
|
|
2328
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
2329
|
+
renameat2 = getattr(libc, "renameat2", None)
|
|
2330
|
+
if renameat2 is None:
|
|
2331
|
+
raise OSError(errno.ENOSYS, "renameat2 is required for atomic no-replace install")
|
|
2332
|
+
renameat2.argtypes = [
|
|
2333
|
+
ctypes.c_int,
|
|
2334
|
+
ctypes.c_char_p,
|
|
2335
|
+
ctypes.c_int,
|
|
2336
|
+
ctypes.c_char_p,
|
|
2337
|
+
ctypes.c_uint,
|
|
2338
|
+
]
|
|
2339
|
+
renameat2.restype = ctypes.c_int
|
|
2340
|
+
result = renameat2(
|
|
2341
|
+
active_parent_fd,
|
|
2342
|
+
os.fsencode(source.name),
|
|
2343
|
+
active_parent_fd,
|
|
2344
|
+
os.fsencode(destination.name),
|
|
2345
|
+
1, # RENAME_NOREPLACE: report EEXIST instead of replacing the destination.
|
|
2346
|
+
)
|
|
2347
|
+
if result != 0:
|
|
2348
|
+
error = ctypes.get_errno()
|
|
2349
|
+
if error == errno.EEXIST:
|
|
2350
|
+
raise FileExistsError(error, os.strerror(error), destination)
|
|
2351
|
+
raise OSError(error, os.strerror(error), destination)
|
|
2352
|
+
elif sys.platform == "darwin":
|
|
2353
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
2354
|
+
renameatx_np = libc.renameatx_np
|
|
2355
|
+
renameatx_np.argtypes = [
|
|
2356
|
+
ctypes.c_int,
|
|
2357
|
+
ctypes.c_char_p,
|
|
2358
|
+
ctypes.c_int,
|
|
2359
|
+
ctypes.c_char_p,
|
|
2360
|
+
ctypes.c_uint,
|
|
2361
|
+
]
|
|
2362
|
+
renameatx_np.restype = ctypes.c_int
|
|
2363
|
+
result = renameatx_np(
|
|
2364
|
+
active_parent_fd,
|
|
2365
|
+
os.fsencode(source.name),
|
|
2366
|
+
active_parent_fd,
|
|
2367
|
+
os.fsencode(destination.name),
|
|
2368
|
+
# RENAME_EXCL | RENAME_NOFOLLOW_ANY: the same no-replace guarantee,
|
|
2369
|
+
# plus a refusal to traverse a symlink in either path.
|
|
2370
|
+
0x00000004 | 0x00000010,
|
|
2371
|
+
)
|
|
2372
|
+
if result != 0:
|
|
2373
|
+
error = ctypes.get_errno()
|
|
2374
|
+
if error == errno.EEXIST:
|
|
2375
|
+
raise FileExistsError(error, os.strerror(error), destination)
|
|
2376
|
+
raise OSError(error, os.strerror(error), destination)
|
|
2377
|
+
else:
|
|
2378
|
+
raise OSError(errno.ENOSYS, "atomic no-replace directory install is unsupported")
|
|
2379
|
+
|
|
2380
|
+
destination_named = os.stat(
|
|
2381
|
+
destination.name,
|
|
2382
|
+
dir_fd=active_parent_fd,
|
|
2383
|
+
follow_symlinks=False,
|
|
2384
|
+
)
|
|
2385
|
+
if _entry_identity(destination_named) != expected:
|
|
2386
|
+
raise OSError(errno.ESTALE, "installed review identity changed", destination)
|
|
2387
|
+
if installed_mode is not None:
|
|
2388
|
+
os.fchmod(active_source_fd, installed_mode)
|
|
2389
|
+
os.fsync(active_source_fd)
|
|
2390
|
+
installed_opened = os.fstat(active_source_fd)
|
|
2391
|
+
if _entry_identity(installed_opened) != expected:
|
|
2392
|
+
raise OSError(errno.ESTALE, "installed review identity changed", destination)
|
|
2393
|
+
if installed_mode is not None and stat.S_IMODE(installed_opened.st_mode) != installed_mode:
|
|
2394
|
+
raise OSError(errno.EPERM, "installed review mode could not be sealed", destination)
|
|
2395
|
+
destination_after_seal = os.stat(
|
|
2396
|
+
destination.name,
|
|
2397
|
+
dir_fd=active_parent_fd,
|
|
2398
|
+
follow_symlinks=False,
|
|
2399
|
+
)
|
|
2400
|
+
if _entry_identity(destination_after_seal) != expected:
|
|
2401
|
+
raise OSError(errno.ESTALE, "installed review identity changed", destination)
|
|
2402
|
+
if (
|
|
2403
|
+
installed_mode is not None
|
|
2404
|
+
and stat.S_IMODE(destination_after_seal.st_mode) != installed_mode
|
|
2405
|
+
):
|
|
2406
|
+
raise OSError(errno.EPERM, "installed review mode could not be sealed", destination)
|
|
2407
|
+
finally:
|
|
2408
|
+
if owned_source_fd and active_source_fd >= 0:
|
|
2409
|
+
os.close(active_source_fd)
|
|
2410
|
+
if owned_parent_fd and active_parent_fd >= 0:
|
|
2411
|
+
os.close(active_parent_fd)
|
|
2412
|
+
|
|
2413
|
+
|
|
2414
|
+
def _remove_tree(
|
|
2415
|
+
path: Path,
|
|
2416
|
+
*,
|
|
2417
|
+
expected_identity: tuple[int, int, int] | None = None,
|
|
2418
|
+
) -> None:
|
|
2419
|
+
"""Best-effort removal that never follows or chmods a symlink target."""
|
|
2420
|
+
|
|
2421
|
+
if os.name == "nt":
|
|
2422
|
+
_remove_tree_fallback(path, expected_identity=expected_identity)
|
|
2423
|
+
return
|
|
2424
|
+
parent_fd = -1
|
|
2425
|
+
try:
|
|
2426
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
2427
|
+
parent_fd = os.open(path.parent, flags)
|
|
2428
|
+
named = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False)
|
|
2429
|
+
expected = expected_identity or _entry_identity(named)
|
|
2430
|
+
if _entry_identity(named) != expected:
|
|
2431
|
+
return
|
|
2432
|
+
_remove_at(parent_fd, path.name, expected_identity=expected)
|
|
2433
|
+
except (FileNotFoundError, NotADirectoryError):
|
|
2434
|
+
return
|
|
2435
|
+
except OSError:
|
|
2436
|
+
return
|
|
2437
|
+
finally:
|
|
2438
|
+
if parent_fd >= 0:
|
|
2439
|
+
os.close(parent_fd)
|
|
2440
|
+
|
|
2441
|
+
|
|
2442
|
+
def _remove_at(
|
|
2443
|
+
parent_fd: int,
|
|
2444
|
+
name: str,
|
|
2445
|
+
*,
|
|
2446
|
+
expected_identity: tuple[int, int, int] | None = None,
|
|
2447
|
+
) -> None:
|
|
2448
|
+
try:
|
|
2449
|
+
info = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
2450
|
+
except FileNotFoundError:
|
|
2451
|
+
return
|
|
2452
|
+
expected = expected_identity or _entry_identity(info)
|
|
2453
|
+
if _entry_identity(info) != expected:
|
|
2454
|
+
return
|
|
2455
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
2456
|
+
try:
|
|
2457
|
+
current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
2458
|
+
if _entry_identity(current) != expected:
|
|
2459
|
+
return
|
|
2460
|
+
os.unlink(name, dir_fd=parent_fd)
|
|
2461
|
+
except FileNotFoundError:
|
|
2462
|
+
pass
|
|
2463
|
+
return
|
|
2464
|
+
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
2465
|
+
try:
|
|
2466
|
+
directory_fd = os.open(name, flags, dir_fd=parent_fd)
|
|
2467
|
+
except FileNotFoundError:
|
|
2468
|
+
return
|
|
2469
|
+
except OSError:
|
|
2470
|
+
return
|
|
2471
|
+
try:
|
|
2472
|
+
opened = os.fstat(directory_fd)
|
|
2473
|
+
if _entry_identity(opened) != expected:
|
|
2474
|
+
return
|
|
2475
|
+
os.fchmod(directory_fd, 0o700)
|
|
2476
|
+
for child in os.listdir(directory_fd):
|
|
2477
|
+
try:
|
|
2478
|
+
child_info = os.stat(child, dir_fd=directory_fd, follow_symlinks=False)
|
|
2479
|
+
_remove_at(
|
|
2480
|
+
directory_fd,
|
|
2481
|
+
child,
|
|
2482
|
+
expected_identity=_entry_identity(child_info),
|
|
2483
|
+
)
|
|
2484
|
+
except OSError:
|
|
2485
|
+
continue
|
|
2486
|
+
finally:
|
|
2487
|
+
os.close(directory_fd)
|
|
2488
|
+
try:
|
|
2489
|
+
current = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
2490
|
+
if _entry_identity(current) != expected:
|
|
2491
|
+
return
|
|
2492
|
+
os.rmdir(name, dir_fd=parent_fd)
|
|
2493
|
+
except FileNotFoundError:
|
|
2494
|
+
pass
|
|
2495
|
+
|
|
2496
|
+
|
|
2497
|
+
def _remove_tree_fallback(
|
|
2498
|
+
path: Path,
|
|
2499
|
+
*,
|
|
2500
|
+
expected_identity: tuple[int, int, int] | None = None,
|
|
2501
|
+
) -> None:
|
|
2502
|
+
try:
|
|
2503
|
+
info = path.lstat()
|
|
2504
|
+
except FileNotFoundError:
|
|
2505
|
+
return
|
|
2506
|
+
expected = expected_identity or _entry_identity(info)
|
|
2507
|
+
if _entry_identity(info) != expected:
|
|
2508
|
+
return
|
|
2509
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
2510
|
+
try:
|
|
2511
|
+
if _entry_identity(path.lstat()) != expected:
|
|
2512
|
+
return
|
|
2513
|
+
path.unlink()
|
|
2514
|
+
except OSError:
|
|
2515
|
+
pass
|
|
2516
|
+
return
|
|
2517
|
+
try:
|
|
2518
|
+
path.chmod(0o700)
|
|
2519
|
+
children = list(path.iterdir())
|
|
2520
|
+
except OSError:
|
|
2521
|
+
return
|
|
2522
|
+
for child in children:
|
|
2523
|
+
try:
|
|
2524
|
+
child_identity = _entry_identity(child.lstat())
|
|
2525
|
+
except FileNotFoundError:
|
|
2526
|
+
continue
|
|
2527
|
+
_remove_tree_fallback(child, expected_identity=child_identity)
|
|
2528
|
+
try:
|
|
2529
|
+
if _entry_identity(path.lstat()) != expected:
|
|
2530
|
+
return
|
|
2531
|
+
path.rmdir()
|
|
2532
|
+
except OSError:
|
|
2533
|
+
pass
|