From 4f3aab394ef920bc1b0dbf02c0ba7b5abb69f90c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 02:52:21 +0700 Subject: [PATCH 1/9] feat: align Registry Stack commands and parameters Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 1 + .github/workflows/release-candidate.yml | 79 +- Cargo.lock | 20 + Cargo.toml | 3 + crates/CLIENTS.md | 2 +- crates/registry-breg-client-node/README.md | 10 +- .../__test__/binding.test.js | 45 +- .../__test__/exchange-authorization.test.js | 20 + .../__test__/facade.test-d.ts | 20 +- .../__test__/immediate-actions.test.js | 2 +- .../__test__/mutation-retry.test.js | 20 +- .../__test__/read-parity.test.js | 2 +- .../__test__/token-exchange.test.js | 8 +- crates/registry-breg-client-node/client.d.ts | 42 +- crates/registry-breg-client-node/index.d.ts | 6 +- crates/registry-breg-client-node/src/lib.rs | 279 +- crates/registry-breg-client-py/README.md | 11 +- .../python/registry_breg_client/__init__.pyi | 10 +- crates/registry-breg-client-py/src/convert.rs | 24 +- crates/registry-breg-client-py/src/lib.rs | 72 +- .../tests/python/test_construction.py | 38 +- .../tests/python/test_mutation_parity.py | 2 +- .../tests/python/test_mutation_retry.py | 8 +- crates/registry-breg-client/README.md | 6 +- crates/registry-breg-client/src/client.rs | 24 +- crates/registry-breg-client/src/config.rs | 61 +- crates/registry-breg-client/src/lib.rs | 8 +- crates/registry-breg-client/src/proposal.rs | 4 +- crates/registry-breg-client/src/transport.rs | 8 +- .../tests/http_boundary.rs | 2 +- .../tests/mutation_retry.rs | 10 +- .../tests/proposal_document_boundary.rs | 4 +- .../registry-breg-client/tests/read_parity.rs | 14 +- .../tests/write_http_boundary.rs | 2 +- crates/registry-breg-mcp/README.md | 8 +- crates/registry-breg-mcp/src/config.rs | 61 + crates/registry-breg-mcp/src/main.rs | 52 +- crates/registry-breg-mcp/src/outbound.rs | 6 +- crates/registry-breg-review/README.md | 15 +- crates/registry-breg-review/src/config.rs | 61 + crates/registry-breg-review/src/lib.rs | 4 +- crates/registry-breg-review/src/main.rs | 51 +- .../src/action_evidence_client.rs | 2 +- crates/registry-breg/src/main.rs | 39 +- crates/registry-breg/src/review_store.rs | 4 +- crates/registry-breg/src/runtime_config.rs | 40 +- crates/registry-breg/src/startup.rs | 10 + .../registry-breg/tests/postgres_refactor.rs | 2 +- crates/registry-breg/tests/runtime_config.rs | 42 + crates/registry-breg/tests/startup_http.rs | 12 +- crates/registry-bregctl/src/data_lifecycle.rs | 2 +- .../src/dev/examples/review.rs | 36 +- crates/registry-bregctl/src/dev/mod.rs | 11 +- crates/registry-bregctl/src/lib.rs | 307 ++- .../registry-bregctl/src/selected_binary.rs | 238 ++ crates/registry-bregctl/tests/serve.rs | 173 ++ .../tests/supporting_services.rs | 355 +++ crates/registry-casework-breg/src/config.rs | 6 +- .../registry-casework-client-node/README.md | 4 +- .../__test__/facade.test-d.ts | 12 +- .../__test__/facade.test.js | 15 +- .../__test__/mutation-retry.test.js | 16 +- .../registry-casework-client-node/client.d.ts | 12 +- .../registry-casework-client-node/client.js | 40 +- .../registry-casework-client-node/index.d.ts | 6 +- .../registry-casework-client-node/src/lib.rs | 35 +- crates/registry-casework-client-py/README.md | 4 +- .../registry_casework_client/__init__.pyi | 2 +- crates/registry-casework-client-py/src/lib.rs | 73 +- .../tests/python/test_construction.py | 34 + .../tests/python/test_mutation_retry.py | 8 +- .../tests/python/test_typing_contract.py | 26 + crates/registry-casework-client/README.md | 6 +- crates/registry-casework-client/src/client.rs | 30 +- crates/registry-casework-client/src/config.rs | 63 +- crates/registry-casework-client/src/lib.rs | 2 +- .../tests/mutation_retry.rs | 10 +- crates/registry-casework/tests/review_http.rs | 2 +- crates/registry-caseworkctl/src/dev/mod.rs | 11 +- crates/registry-caseworkctl/src/lib.rs | 191 +- .../src/selected_binary.rs | 252 ++ .../tests/dev_lifecycle.rs | 2 +- crates/registry-caseworkctl/tests/serve.rs | 150 + crates/registry-cli-docs/Cargo.toml | 2 + crates/registry-cli-docs/catalog.json | 2418 ++++++++++++++--- crates/registry-cli-docs/src/lib.rs | 73 + .../README.md | 2 +- .../__test__/facade.test-d.ts | 10 +- .../__test__/facade.test.js | 62 +- .../__test__/native.test-d.ts | 6 +- .../client.d.ts | 15 +- .../src/lib.rs | 34 +- .../registry-coordinator-client-py/README.md | 8 +- .../registry_coordinator_client/__init__.pyi | 4 +- .../registry-coordinator-client-py/src/lib.rs | 20 +- .../tests/python/test_construction.py | 46 +- .../tests/python/test_mutation_retry.py | 10 +- .../tests/python/test_native_requests.py | 2 +- crates/registry-coordinator-client/README.md | 2 +- .../registry-coordinator-client/src/client.rs | 22 +- .../registry-coordinator-client/src/config.rs | 36 +- crates/registry-coordinator-client/src/lib.rs | 2 +- .../registry-coordinator-client/tests/http.rs | 22 +- crates/registry-coordinator/Cargo.toml | 1 + crates/registry-coordinator/src/adapters.rs | 32 +- crates/registry-coordinator/src/cli.rs | 157 +- .../src/cli/selected_binary.rs | 203 ++ crates/registry-coordinator/src/operations.rs | 6 +- .../tests/breg_action_adapter.rs | 2 +- .../tests/canonical_model.rs | 14 +- .../registry-coordinator/tests/cli_remote.rs | 70 + .../tests/deployment_postgres.rs | 4 +- .../tests/scheduling_adapter.rs | 2 +- crates/registry-coordinator/tests/serve.rs | 205 ++ .../tests/support/breg_service.rs | 2 +- .../registry-discovery-client-node/README.md | 3 + .../__test__/surface.test-d.ts | 13 +- .../__test__/surface.test.js | 13 +- .../client.d.ts | 4 +- .../registry-discovery-client-node/client.js | 6 +- .../registry-discovery-client-node/index.d.ts | 4 +- .../registry-discovery-client-node/src/lib.rs | 37 +- crates/registry-discovery-client-py/README.md | 2 + .../registry_discovery_client/__init__.pyi | 2 +- .../registry-discovery-client-py/src/lib.rs | 8 +- .../tests/python/test_client.py | 17 +- .../tests/python/test_drift.py | 18 + .../registry-discovery-client/src/client.rs | 16 +- .../tests/native_journey.rs | 2 +- crates/registry-discoveryctl/src/lib.rs | 75 +- .../src/selected_binary.rs | 203 ++ crates/registry-discoveryctl/tests/serve.rs | 205 ++ .../registry-evidence-client-node/README.md | 19 +- .../__test__/construction.test.js | 58 +- .../__test__/discovery.test.js | 4 +- .../__test__/drift.test.js | 11 +- .../__test__/errors.test.js | 4 +- .../registry-evidence-client-node/client.d.ts | 25 +- .../registry-evidence-client-node/index.d.ts | 2 +- .../src/convert.rs | 266 +- .../registry-evidence-client-node/src/lib.rs | 2 +- crates/registry-evidence-client-py/README.md | 12 +- .../registry_evidence_client/__init__.pyi | 4 +- .../src/convert.rs | 70 +- crates/registry-evidence-client-py/src/lib.rs | 89 +- .../tests/python/test_construction.py | 51 +- .../tests/python/test_discovery.py | 4 +- .../tests/python/test_errors.py | 2 +- crates/registry-evidence-client/src/client.rs | 41 +- crates/registry-evidence-client/src/config.rs | 54 +- crates/registry-evidence-client/src/lib.rs | 4 +- .../tests/against_a_real_deployment.rs | 14 +- .../src/evidence_binary.rs | 340 ++- crates/registry-evidencectl/src/lib.rs | 55 +- crates/registry-evidencectl/src/oid4vci.rs | 166 ++ crates/registry-evidencectl/src/source_add.rs | 124 +- crates/registry-evidencectl/tests/oid4vci.rs | 78 + .../tests/source_add_cli.rs | 8 +- crates/registry-manifest-cli/Cargo.toml | 1 + crates/registry-manifest-cli/README.md | 23 +- crates/registry-manifest-cli/src/lib.rs | 148 + crates/registry-manifest-cli/src/main.rs | 268 +- crates/registry-manifest-cli/tests/cli.rs | 291 +- .../registry-messaging-client-node/README.md | 2 +- .../__test__/facade.test-d.ts | 12 +- .../__test__/facade.test.js | 64 +- .../client.d.ts | 13 +- .../registry-messaging-client-node/src/lib.rs | 34 +- crates/registry-messaging-client-py/README.md | 2 +- .../registry_messaging_client/__init__.pyi | 2 +- .../registry-messaging-client-py/src/lib.rs | 63 +- .../tests/python/test_construction.py | 48 +- .../tests/python/test_mutation_retry.py | 8 +- .../registry-messaging-client/src/client.rs | 20 +- .../registry-messaging-client/src/config.rs | 55 +- crates/registry-messaging-client/src/lib.rs | 2 +- .../tests/mutation_retry.rs | 10 +- crates/registry-messaging/src/http.rs | 24 +- crates/registry-messagingctl/src/lib.rs | 100 +- .../src/selected_binary.rs | 209 ++ crates/registry-messagingctl/tests/serve.rs | 201 ++ .../src/client/exchange_config.rs | 72 +- .../src/client/private_key_jwt.rs | 5 +- crates/registry-render/src/server.rs | 48 +- crates/registry-review-client/src/client.rs | 18 +- crates/registry-review-client/src/config.rs | 26 +- .../tests/http_boundary.rs | 2 +- crates/registry-rstack/Cargo.toml | 30 + crates/registry-rstack/README.md | 95 + crates/registry-rstack/install.sh | 287 ++ crates/registry-rstack/src/assets.rs | 188 ++ crates/registry-rstack/src/cli.rs | 1122 ++++++++ crates/registry-rstack/src/install.rs | 1099 ++++++++ crates/registry-rstack/src/lib.rs | 19 + crates/registry-rstack/src/main.rs | 3 + crates/registry-rstack/src/model.rs | 341 +++ crates/registry-rstack/src/store.rs | 1324 +++++++++ crates/registry-rstack/tests/assets.rs | 75 + crates/registry-rstack/tests/bootstrap.rs | 212 ++ crates/registry-rstack/tests/cli.rs | 256 ++ .../tests/component_selection.rs | 77 + crates/registry-rstack/tests/dispatch.rs | 303 +++ crates/registry-rstack/tests/manager.rs | 791 ++++++ .../registry-rstack/tests/release_manifest.rs | 68 + crates/registry-rstack/tests/store.rs | 298 ++ .../registry-scheduling-client-node/README.md | 2 +- .../__test__/facade.test-d.ts | 12 +- .../__test__/facade.test.js | 66 +- .../client.d.ts | 13 +- .../src/lib.rs | 34 +- .../registry-scheduling-client-py/README.md | 2 +- .../registry_scheduling_client/__init__.pyi | 2 +- .../registry-scheduling-client-py/src/lib.rs | 75 +- .../tests/python/test_construction.py | 67 +- .../tests/python/test_mutation_retry.py | 8 +- crates/registry-scheduling-client/README.md | 2 +- .../registry-scheduling-client/src/client.rs | 18 +- .../registry-scheduling-client/src/config.rs | 56 +- crates/registry-scheduling-client/src/lib.rs | 2 +- .../tests/http_boundary.rs | 2 +- .../tests/mutation_retry.rs | 10 +- crates/registry-scheduling/src/http.rs | 35 + crates/registry-scheduling/src/store.rs | 35 + crates/registry-schedulingctl/src/lib.rs | 165 +- .../src/selected_binary.rs | 205 ++ .../tests/intents_postgres.rs | 14 +- crates/registry-schedulingctl/tests/serve.rs | 201 ++ crates/registry-stack-client-node/README.md | 2 +- .../__test__/facade.test-d.ts | 34 +- .../breg/client.d.ts | 42 +- .../breg/index.d.ts | 6 +- .../casework/client.d.ts | 12 +- .../casework/client.js | 40 +- .../casework/index.d.ts | 6 +- .../discovery/client.d.ts | 4 +- .../discovery/client.js | 6 +- .../discovery/index.d.ts | 4 +- .../evidence/client.d.ts | 25 +- .../evidence/index.d.ts | 2 +- .../messaging/client.d.ts | 13 +- .../scheduling/client.d.ts | 13 +- docs/site/scripts/generate-cli-reference.mjs | 4 + docs/site/src/content/docs/changelog.mdx | 2 +- .../reference/apis/registry-scheduling.mdx | 4 +- .../src/content/docs/reference/client-api.mdx | 75 +- docs/site/src/data/cli-reference.yaml | 2 +- products/breg/CHANGELOG.md | 4 + products/breg/README.md | 5 + .../breg/examples/mcp-runtime/runtime.yaml | 1 + .../examples/minimal/runtime.example.yaml | 1 + .../breg/examples/review-runtime/runtime.yaml | 1 + .../runtime.example.yaml | 1 + .../mcp-runtime/mcp-runtime.schema.json | 14 + .../review-runtime/review-runtime.schema.json | 14 + .../generated/runtime/runtime.schema.json | 14 + products/coordinator/SECURITY-REVIEW-NOTES.md | 36 + .../generated/operations/README.md | 60 +- .../generated/operations/catalog.json | 60 +- products/manifest/CHANGELOG.md | 8 + products/manifest/README.md | 4 +- products/manifest/docs/reference.md | 15 +- products/manifest/docs/release-notes.md | 6 + products/manifest/docs/validate-and-render.md | 28 +- products/manifest/examples/README.md | 4 +- .../manifest/scripts/check-contract-kernel.sh | 2 +- products/manifest/scripts/itb-semic-smoke.sh | 2 +- release/notes/v0.44.0.md | 70 + .../schemas/release-candidate-v2.schema.json | 87 + release/scripts/build-release-binaries.sh | 14 + .../scripts/build-release-native-platform.sh | 15 + release/scripts/release_candidate.py | 22 +- release/scripts/release_roster.py | 16 + release/scripts/rstack_components.py | 187 ++ release/scripts/test_release_candidate.py | 8 + release/scripts/test_release_roster.py | 13 + release/scripts/test_rstack_components.py | 59 + 276 files changed, 17671 insertions(+), 1820 deletions(-) create mode 100644 crates/registry-bregctl/src/selected_binary.rs create mode 100644 crates/registry-bregctl/tests/serve.rs create mode 100644 crates/registry-bregctl/tests/supporting_services.rs create mode 100644 crates/registry-caseworkctl/src/selected_binary.rs create mode 100644 crates/registry-caseworkctl/tests/serve.rs create mode 100644 crates/registry-coordinator/src/cli/selected_binary.rs create mode 100644 crates/registry-coordinator/tests/serve.rs create mode 100644 crates/registry-discoveryctl/src/selected_binary.rs create mode 100644 crates/registry-discoveryctl/tests/serve.rs create mode 100644 crates/registry-evidencectl/src/oid4vci.rs create mode 100644 crates/registry-evidencectl/tests/oid4vci.rs create mode 100644 crates/registry-messagingctl/src/selected_binary.rs create mode 100644 crates/registry-messagingctl/tests/serve.rs create mode 100644 crates/registry-rstack/Cargo.toml create mode 100644 crates/registry-rstack/README.md create mode 100644 crates/registry-rstack/install.sh create mode 100644 crates/registry-rstack/src/assets.rs create mode 100644 crates/registry-rstack/src/cli.rs create mode 100644 crates/registry-rstack/src/install.rs create mode 100644 crates/registry-rstack/src/lib.rs create mode 100644 crates/registry-rstack/src/main.rs create mode 100644 crates/registry-rstack/src/model.rs create mode 100644 crates/registry-rstack/src/store.rs create mode 100644 crates/registry-rstack/tests/assets.rs create mode 100644 crates/registry-rstack/tests/bootstrap.rs create mode 100644 crates/registry-rstack/tests/cli.rs create mode 100644 crates/registry-rstack/tests/component_selection.rs create mode 100644 crates/registry-rstack/tests/dispatch.rs create mode 100644 crates/registry-rstack/tests/manager.rs create mode 100644 crates/registry-rstack/tests/release_manifest.rs create mode 100644 crates/registry-rstack/tests/store.rs create mode 100644 crates/registry-schedulingctl/src/selected_binary.rs create mode 100644 crates/registry-schedulingctl/tests/serve.rs create mode 100644 release/notes/v0.44.0.md create mode 100644 release/scripts/rstack_components.py create mode 100644 release/scripts/test_rstack_components.py diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index fbc3015250..8398136b0b 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -102,6 +102,7 @@ "registry-coordinator-client-node", "registry-coordinator-client-py", "registry-thunderid-tooling", + "registry-rstack", "registry-cli-docs", "registry-cli-reference", "registry-language-server", diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index e07ac2ed10..f7da89afd6 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -624,6 +624,13 @@ jobs: -p registry-evidence -p registry-evidencectl \ -p registry-evidence-oid4vci \ --target "${{ matrix.target }}" + rstack_in_release="$(python3 release/scripts/release_roster.py \ + rstack-in-release "${{ needs.validate.outputs.version }}")" + if [[ "${rstack_in_release}" == true ]]; then + cargo build --release --locked \ + -p registry-rstack --bin rstack \ + --target "${{ matrix.target }}" + fi IFS=. read -r release_major release_minor _release_patch \ <<<"${{ needs.validate.outputs.version }}" include_breg=0 @@ -664,6 +671,13 @@ jobs: --target "${{ matrix.target }}" fi mkdir -p platform + if [[ "${rstack_in_release}" == true ]]; then + asset="rstack-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" + cp "target/${{ matrix.target }}/release/rstack" "platform/${asset}" + chmod 0755 "platform/${asset}" + test "$("platform/${asset}" --version)" = \ + "rstack ${{ needs.validate.outputs.version }}" + fi for evidence_binary in evidence evidencectl evidence-oid4vci; do asset="${evidence_binary}-${{ needs.validate.outputs.tag }}-${{ matrix.asset }}" cp "target/${{ matrix.target }}/release/${evidence_binary}" \ @@ -2042,6 +2056,24 @@ jobs: "${binary} ${{ needs.validate.outputs.version }}" done rm candidate/bundle-root/SHA256SUMS + if [[ "$(python3 release/scripts/release_roster.py \ + rstack-in-release "${version}")" == true ]]; then + rstack_installer="rstack-${{ needs.validate.outputs.tag }}-install.sh" + awk -v version="${{ needs.validate.outputs.tag }}" ' + $0 == "default_version=\"\"" { + print "default_version=\"" version "\"" + rendered = 1 + next + } + { print } + END { if (!rendered) exit 1 } + ' crates/registry-rstack/install.sh \ + > "candidate/bundle-root/${rstack_installer}" + chmod 0755 "candidate/bundle-root/${rstack_installer}" + cp "candidate/bundle-root/${rstack_installer}" \ + candidate/bundle-root/rstack-install.sh + chmod 0755 candidate/bundle-root/rstack-install.sh + fi for name in ${RELEASE_IMAGE_NAMES}; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" digest="${candidate_ref##*@}" @@ -2067,6 +2099,7 @@ jobs: set -euo pipefail canonical="inputs/candidate-canonical-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" tag="${{ needs.validate.outputs.tag }}" + version="${{ needs.validate.outputs.version }}" bundle="candidate/registry-stack-${tag}-candidate.tar.gz" created_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" expires_at="$(date -u -d "${created_at} + 7 days" +%Y-%m-%dT%H:%M:%SZ)" @@ -2097,6 +2130,12 @@ jobs: '{name:$name,kind:$kind,size:$size,sha256:$sha256}' done | jq -s . )" + components=null + if [[ "$(python3 release/scripts/release_roster.py \ + rstack-in-release "${version}")" == true ]]; then + components="$(printf '%s' "${payloads}" \ + | python3 release/scripts/rstack_components.py --version "${version}")" + fi images="$( for name in ${RELEASE_IMAGE_NAMES}; do candidate_ref="$(tr -d '\n' < "${canonical}/dist/images/${name}.digest")" @@ -2134,6 +2173,7 @@ jobs: --arg created_at "${created_at}" \ --arg expires_at "${expires_at}" \ --argjson payloads "${payloads}" \ + --argjson components "${components}" \ --argjson images "${images}" \ --arg docs_name "${docs_name}" \ --arg docs_sha "$(sha256sum "candidate/bundle-root/${docs_name}" | awk '{print $1}')" \ @@ -2179,11 +2219,48 @@ jobs: size: $bundle_size, sha256: $bundle_sha } - }' > candidate/candidate-manifest.draft.json + } + (if $components == null then {} else {components:$components} end)' \ + > candidate/candidate-manifest.draft.json python3 release/scripts/release_candidate.py seal-candidate \ --draft candidate/candidate-manifest.draft.json \ --output candidate/release-candidate-manifest.json + - name: Smoke release-pinned rstack installation from verified local assets + if: ${{ needs.validate.outputs.version != '' }} + shell: bash + run: | + set -euo pipefail + version="${{ needs.validate.outputs.version }}" + if [[ "$(python3 release/scripts/release_roster.py \ + rstack-in-release "${version}")" != true ]]; then + exit 0 + fi + tag="${{ needs.validate.outputs.tag }}" + assets="${GITHUB_WORKSPACE}/candidate/bundle-root" + cp candidate/release-candidate-manifest.json \ + "${assets}/registry-stack-${tag}-release-manifest.json" + ( + cd "${assets}" + sha256sum -- * > SHA256SUMS + ) + destination="${RUNNER_TEMP}/rstack-install-smoke" + bash "${assets}/rstack-${tag}-install.sh" \ + --asset-dir "${assets}" \ + --bin-dir "${destination}/bin" \ + --install-root "${destination}/lib" \ + breg evidence + test -L "${destination}/bin/rstack" + test "$(find "${destination}/bin" -mindepth 1 -maxdepth 1 | wc -l)" -eq 1 + test "$("${destination}/bin/rstack" --version)" = "rstack ${version}" + "${destination}/bin/rstack" components list --format json \ + | jq -e '.components | any(.name == "breg" and .installed == true)' \ + >/dev/null + "${destination}/bin/rstack" components list --format json \ + | jq -e '.components | any(.name == "evidence" and .installed == true)' \ + >/dev/null + test "$("${destination}/bin/rstack" breg --version)" = "bregctl ${version}" + test "$("${destination}/bin/rstack" evidence --version)" = "evidencectl ${version}" + - name: Upload one candidate manifest and bundle uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: diff --git a/Cargo.lock b/Cargo.lock index 58155978f4..aab6c3ca32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5397,9 +5397,11 @@ dependencies = [ "registry-evidence", "registry-evidence-oid4vci", "registry-evidencectl", + "registry-manifest-cli", "registry-messaging", "registry-messagingctl", "registry-render", + "registry-rstack", "registry-scheduling", "registry-schedulingctl", "serde", @@ -5451,6 +5453,7 @@ dependencies = [ "registry-scheduling-core", "reqwest 0.12.28", "rhai", + "rustix 1.1.5", "rustls", "schemars", "serde", @@ -5938,6 +5941,7 @@ dependencies = [ name = "registry-manifest-cli" version = "0.44.0" dependencies = [ + "clap", "jsonschema", "registry-manifest-core", "registry-platform-buildinfo", @@ -6516,6 +6520,22 @@ dependencies = [ "uuid", ] +[[package]] +name = "registry-rstack" +version = "0.44.0" +dependencies = [ + "clap", + "fs2", + "hex", + "registry-platform-canonical-json", + "rustix 1.1.5", + "serde", + "serde_json", + "sha2 0.11.1", + "tempfile", + "ureq", +] + [[package]] name = "registry-scheduling" version = "0.44.0" diff --git a/Cargo.toml b/Cargo.toml index 376181969a..df52dae5ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ members = [ "crates/registry-thunderid-tooling", "crates/registry-manifest-core", "crates/registry-manifest-cli", + "crates/registry-rstack", "crates/registry-record", "crates/registry-render", "crates/registry-breg-client", @@ -118,6 +119,8 @@ registry-evidencectl = { path = "crates/registry-evidencectl", version = "0.44.0 registry-language-server = { path = "crates/registry-language-server", version = "0.44.0" } registry-linkml = { path = "crates/registry-linkml", version = "0.44.0" } registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.44.0" } +registry-manifest-cli = { path = "crates/registry-manifest-cli", version = "0.44.0" } +registry-rstack = { path = "crates/registry-rstack", version = "0.44.0" } registry-record = { path = "crates/registry-record", version = "0.44.0" } registry-render = { path = "crates/registry-render", version = "0.44.0" } registry-breg-client = { path = "crates/registry-breg-client", version = "0.44.0" } diff --git a/crates/CLIENTS.md b/crates/CLIENTS.md index d028d0fa4d..8c368e35b6 100644 --- a/crates/CLIENTS.md +++ b/crates/CLIENTS.md @@ -39,7 +39,7 @@ or unify distinct authority contracts. Shared HTTP/token primitives live in with the attempt rolled back), and never after a 4xx status line: `registry_platform_httputil::client::retry_keyed_mutation` owns its loop and waits, the BReg, Casework, Coordinator, Messaging, and Scheduling clients - expose the count as `with_max_mutation_retries` (2 by default, at most 2, + expose the count as `with_maximum_mutation_retries` (2 by default, at most 2, 0 disables), and the resend repeats the caller's exact key, headers, and body. The other clients never resend. Reads and unkeyed operations are never resent, and diff --git a/crates/registry-breg-client-node/README.md b/crates/registry-breg-client-node/README.md index 14392dac14..ef4db430c9 100644 --- a/crates/registry-breg-client-node/README.md +++ b/crates/registry-breg-client-node/README.md @@ -114,11 +114,19 @@ Fixed-scale decimals remain JSON strings. Null and absent members remain distinc Do not round unsupported input to make it pass. JSON whitespace and equivalent number spellings may be canonicalized; the promise concerns values and types. +Client configuration uses `maximumResponseBytes`; the retired +`maxResponseBytes` spelling is refused. Optional configuration values are +omitted rather than set to `null`. `trustedRootCertificates` accepts PEM text, +and the supplied bundle keeps the Rust client's explicit trust-root semantics. +`attemptTimeoutMilliseconds` bounds one outbound attempt, including its body, +and `connectTimeoutMilliseconds` bounds connection setup. + Actions retain their original opaque authority and exact retry semantics. A keyed mutation (create, patch, attachment upload or delete, Proposal transition, immediate Action, batch, tombstone, statistics publish or withdraw) whose outcome is unknown is resent identically under the same key up to -`maxMutationRetries` times: 0 to 2, default 2, and 0 sends it once. The +`maximumMutationRetries` additional times: 0 to 2, default 2, and 0 sends it +once, while the default permits up to three total attempts. The outcome is unknown after a timeout or broken exchange once the request was sent, an unusable answer, or a 5xx other than `action.handler_failed` and `statistical_dataset.domain_violation`, which the engine returns only with the diff --git a/crates/registry-breg-client-node/__test__/binding.test.js b/crates/registry-breg-client-node/__test__/binding.test.js index 3977fc875d..dbdf4b4eca 100644 --- a/crates/registry-breg-client-node/__test__/binding.test.js +++ b/crates/registry-breg-client-node/__test__/binding.test.js @@ -146,22 +146,36 @@ test('nullable record options use request defaults', async () => { }); test('constructors reject unsupported fields and unsafe integers', () => { + assert.ok(new BaseRegistryClient({ + baseUrl, + attemptTimeoutMilliseconds: 5_000, + connectTimeoutMilliseconds: 2_000, + maximumMutationRetries: 1, + })); assert.throws(() => new BaseRegistryClient({ baseUrl, retry: true }), (error) => ( error instanceof BaseRegistryClientError && error.kind === 'configuration' )); assert.throws(() => new BaseRegistryClient({ baseUrl, - maxResponseBytes: Number.MAX_SAFE_INTEGER + 1, + maximumResponseBytes: Number.MAX_SAFE_INTEGER + 1, + }), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration'); + assert.throws(() => new BaseRegistryClient({ + baseUrl, + maxResponseBytes: 8 * 1024 * 1024, }), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration'); }); test('a configuration value the sanitizer refuses is a configuration error', () => { for (const [label, config] of [ - ['an unsafe integer', { baseUrl, requestTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], - ['a non-finite number', { baseUrl, maxResponseBytes: Infinity }], + ['an unsafe integer', { baseUrl, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['a negative number', { baseUrl, maximumResponseBytes: -1 }], + ['a fractional number', { baseUrl, maximumResponseBytes: 1.5 }], + ['a non-finite number', { baseUrl, maximumResponseBytes: Infinity }], + ['a null member', { baseUrl, maximumResponseBytes: null }], + ['a binary trust bundle', { baseUrl, trustedRootCertificates: Buffer.from('not PEM text') }], ['an undefined member', { baseUrl, userAgent: undefined }], ['a function', { baseUrl, userAgent: () => 'breg-test' }], - ['a date', { baseUrl, requestTimeoutMilliseconds: new Date(0) }], + ['a date', { baseUrl, attemptTimeoutMilliseconds: new Date(0) }], ['a proxy', new Proxy({ baseUrl }, {})], ['no configuration', undefined], ]) { @@ -174,7 +188,7 @@ test('a configuration value the sanitizer refuses is a configuration error', () } }); -test('nullable private-key JWT durations use provider defaults', () => { +test('omitted private-key JWT durations use provider defaults and null is refused', () => { const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); const clientKey = privateKey.export({ format: 'jwk' }); clientKey.alg = 'ES256'; @@ -186,12 +200,29 @@ test('nullable private-key JWT durations use provider defaults', () => { tokenEndpoint: 'https://issuer.invalid/oauth/token', clientId: 'breg-node-binding-test-client', clientKey, - assertionLifetimeSeconds: null, - refreshMarginSeconds: null, + attemptTimeoutMilliseconds: 5_000, }, }, }; assert.ok(new BaseRegistryClient(config)); + assert.throws(() => new BaseRegistryClient({ + ...config, + authorization: { + privateKeyJwt: { + ...config.authorization.privateKeyJwt, + requestTimeoutMilliseconds: 5_000, + }, + }, + }), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration'); + assert.throws(() => new BaseRegistryClient({ + ...config, + authorization: { + privateKeyJwt: { + ...config.authorization.privateKeyJwt, + assertionLifetimeSeconds: null, + }, + }, + }), (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration'); assert.throws(() => new BaseRegistryClient({ ...config, authorization: { diff --git a/crates/registry-breg-client-node/__test__/exchange-authorization.test.js b/crates/registry-breg-client-node/__test__/exchange-authorization.test.js index 6bfc75e197..1e334e298a 100644 --- a/crates/registry-breg-client-node/__test__/exchange-authorization.test.js +++ b/crates/registry-breg-client-node/__test__/exchange-authorization.test.js @@ -29,6 +29,26 @@ async function form(request) { return new URLSearchParams(Buffer.concat(chunks).toString()); } +test('exchange configuration rejects null, retired keys, and unsafe integers before I/O', () => { + const exchange = { + client: clientConfig('https://issuer.example/token', 'urn:records', ['records:get']), + context: { issuer: 'https://portal.example', subject: 'person-1', audience: 'https://issuer.example', generation: 'verified-1', deadlineSeconds: Math.floor(Date.now() / 1000) + 120 }, + firstParty: { key: key(), attributes: { registry_actor_kind: 'human' } }, + }; + for (const changed of [ + { ...exchange, context: { ...exchange.context, grantId: null } }, + { ...exchange, client: { ...exchange.client, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 } }, + { ...exchange, client: { ...exchange.client, requestTimeoutMilliseconds: 100 } }, + { ...exchange, client: { ...exchange.client, attemptTimeoutSeconds: 0.1 } }, + { ...exchange, client: { ...exchange.client, request_timeout_milliseconds: 100 } }, + ]) { + assert.throws( + () => new BaseRegistryClient({ baseUrl: 'https://registry.example', authorization: { exchange: changed } }), + (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration', + ); + } +}); + test('first-party option signs one verified person context and caches its token', async () => { const exchanges = []; const { server, origin } = await serve(async (request, response) => { diff --git a/crates/registry-breg-client-node/__test__/facade.test-d.ts b/crates/registry-breg-client-node/__test__/facade.test-d.ts index 1b663407c0..48b238c19a 100644 --- a/crates/registry-breg-client-node/__test__/facade.test-d.ts +++ b/crates/registry-breg-client-node/__test__/facade.test-d.ts @@ -30,6 +30,15 @@ import { } from '..' declare const client: BaseRegistryClient +new BaseRegistryClient({ + baseUrl: 'https://registry.example.test/', + maximumResponseBytes: 8 * 1024 * 1024, + trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----', +}) +// @ts-expect-error The abbreviated response-bound spelling is retired. +new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxResponseBytes: 1024 }) +// @ts-expect-error Optional configuration values are omitted rather than set to null. +new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maximumResponseBytes: null }) declare const failure: BaseRegistryClientError if (failure.constraint?.constraintKind === 'compare') { failure.constraint.left.toUpperCase() @@ -236,8 +245,17 @@ client.createRecordJson(create, { wide: 9007199254740992 }, 'exact-create') client.statisticsLive('population', null, null, 'reader', 'csv').then((raw) => raw.reprDigest?.toUpperCase()) -const once = new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxMutationRetries: 0 }) +const once = new BaseRegistryClient({ + baseUrl: 'https://registry.example.test/', + attemptTimeoutMilliseconds: 30_000, + connectTimeoutMilliseconds: 10_000, + maximumMutationRetries: 0, +}) void once +// @ts-expect-error Retired option names are refused before I/O. +new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', requestTimeoutMilliseconds: 30_000 }) +// @ts-expect-error Retired option names are refused before I/O. +new BaseRegistryClient({ baseUrl: 'https://registry.example.test/', maxMutationRetries: 0 }) function mayHaveTakenEffect(error: BaseRegistryClientError): boolean { const unknown: boolean = error.outcomeUnknown diff --git a/crates/registry-breg-client-node/__test__/immediate-actions.test.js b/crates/registry-breg-client-node/__test__/immediate-actions.test.js index 9a18342053..0feb6fb6b1 100644 --- a/crates/registry-breg-client-node/__test__/immediate-actions.test.js +++ b/crates/registry-breg-client-node/__test__/immediate-actions.test.js @@ -111,7 +111,7 @@ test('an immediate action recovers after a lost response from persisted bytes an try { const baseUrl = `http://127.0.0.1:${server.address().port}`; // The process gives up after the lost answer instead of resending it. - const client = new BaseRegistryClient({ baseUrl, maxMutationRetries: 0 }); + const client = new BaseRegistryClient({ baseUrl, maximumMutationRetries: 0 }); const binding = (await client.registryContract('writer')).selectAction('update-item', 'writer'); const conditions = await client.actionTargetConditions(binding, { targetId }); const inputs = { targetId, label: 'new' }; diff --git a/crates/registry-breg-client-node/__test__/mutation-retry.test.js b/crates/registry-breg-client-node/__test__/mutation-retry.test.js index 39c258d44b..d737b43fb2 100644 --- a/crates/registry-breg-client-node/__test__/mutation-retry.test.js +++ b/crates/registry-breg-client-node/__test__/mutation-retry.test.js @@ -74,7 +74,7 @@ test('a publication answered 503 is resent once under the same key and succeeds' test('a retry ceiling of zero sends a publication once and reports the outcome unknown', async (context) => { const { baseUrl, requests } = await serve(context, [unavailable, publication]); - const client = new BaseRegistryClient({ baseUrl, maxMutationRetries: 0 }); + const client = new BaseRegistryClient({ baseUrl, maximumMutationRetries: 0 }); await assert.rejects(publish(client), (error) => { assert.ok(error instanceof BaseRegistryClientError); @@ -95,7 +95,7 @@ test('a refusal or a failure before commit is never resent and reports the outco }), ]) { const { baseUrl, requests } = await serve(context, [refusal, publication]); - const client = new BaseRegistryClient({ baseUrl, maxMutationRetries: 2 }); + const client = new BaseRegistryClient({ baseUrl, maximumMutationRetries: 2 }); await assert.rejects(publish(client), (error) => { assert.equal(error.kind, 'problem'); @@ -108,17 +108,21 @@ test('a refusal or a failure before commit is never resent and reports the outco }); test('a retry ceiling outside zero to two is a configuration error', () => { - for (const maxMutationRetries of [0, 1, 2]) { - assert.ok(new BaseRegistryClient({ baseUrl: 'https://registry.example/', maxMutationRetries })); + for (const maximumMutationRetries of [0, 1, 2]) { + assert.ok(new BaseRegistryClient({ baseUrl: 'https://registry.example/', maximumMutationRetries })); } - for (const maxMutationRetries of [3, 255, 256, -1, 1.5, '1']) { - assert.throws(() => new BaseRegistryClient({ baseUrl: 'https://registry.example/', maxMutationRetries }), (error) => { - assert.ok(error instanceof BaseRegistryClientError, `${maxMutationRetries}`); - assert.equal(error.kind, 'configuration', `${maxMutationRetries}`); + for (const maximumMutationRetries of [3, 255, 256, -1, 1.5, '1']) { + assert.throws(() => new BaseRegistryClient({ baseUrl: 'https://registry.example/', maximumMutationRetries }), (error) => { + assert.ok(error instanceof BaseRegistryClientError, `${maximumMutationRetries}`); + assert.equal(error.kind, 'configuration', `${maximumMutationRetries}`); assert.equal(error.outcomeUnknown, false); return true; }); } + assert.throws( + () => new BaseRegistryClient({ baseUrl: 'https://registry.example/', maxMutationRetries: 1 }), + (error) => error instanceof BaseRegistryClientError && error.kind === 'configuration', + ); }); test('a request refused before any exchange reports the outcome known', async () => { diff --git a/crates/registry-breg-client-node/__test__/read-parity.test.js b/crates/registry-breg-client-node/__test__/read-parity.test.js index 7bb281c3c3..cbb1157350 100644 --- a/crates/registry-breg-client-node/__test__/read-parity.test.js +++ b/crates/registry-breg-client-node/__test__/read-parity.test.js @@ -117,7 +117,7 @@ test('exact JSON reads preserve and follow the largest server cursor envelope', try { const client = new BaseRegistryClient({ baseUrl: `http://127.0.0.1:${server.address().port}`, - maxResponseBytes: 32 * 1024, + maximumResponseBytes: 32 * 1024, }); const first = await client.listRecordsJson('companies', { top: 1, count: true }); assert.equal(first.continuation.skiptoken.length, cursor.length); diff --git a/crates/registry-breg-client-node/__test__/token-exchange.test.js b/crates/registry-breg-client-node/__test__/token-exchange.test.js index f1cf3a1099..fc1e63bfef 100644 --- a/crates/registry-breg-client-node/__test__/token-exchange.test.js +++ b/crates/registry-breg-client-node/__test__/token-exchange.test.js @@ -22,7 +22,8 @@ test('exchange preserves service cache and sends exact independent grant request const clientKey = crypto.generateKeyPairSync('ec', {namedCurve:'prime256v1'}).privateKey.export({format:'jwk'}); Object.assign(clientKey, {alg:'ES256', kid:'synthetic-test'}); const config = {tokenEndpoint:`http://127.0.0.1:${server.address().port}/token`, clientId:'test-client', - clientKey, resource:'urn:test:resource', scopes:['records:get']}; + clientKey, resource:'urn:test:resource', scopes:['records:get'], attemptTimeoutMilliseconds:5_000, + connectTimeoutMilliseconds:2_000}; const provider = new PrivateKeyJwt(config); assert.equal(await provider.bearerToken(), 'service-token'); assert.deepEqual(await Promise.all([provider.exchange('first-subject'), provider.exchange('second-subject')]), @@ -38,7 +39,10 @@ test('exchange preserves service cache and sends exact independent grant request } assert.notEqual(requests[1].get('client_assertion'), requests[2].get('client_assertion')); await assert.rejects(provider.exchange('bad\nsubject'), error => error instanceof BaseRegistryClientError && !error.message.includes('bad')); - await assert.rejects(new PrivateKeyJwt({...config, resource:null}).exchange('subject')); + assert.throws(() => new PrivateKeyJwt({...config, resource:null}), + error => error instanceof BaseRegistryClientError && error.kind === 'configuration'); + assert.throws(() => new PrivateKeyJwt({...config, requestTimeoutMilliseconds:5_000}), + error => error instanceof BaseRegistryClientError && error.kind === 'configuration'); assert.equal(requests.length, 3); } finally { await new Promise(resolve => server.close(resolve)); } }); diff --git a/crates/registry-breg-client-node/client.d.ts b/crates/registry-breg-client-node/client.d.ts index c31200f41d..f821e856b8 100644 --- a/crates/registry-breg-client-node/client.d.ts +++ b/crates/registry-breg-client-node/client.d.ts @@ -62,24 +62,24 @@ export interface PrivateKeyJwtConfig { * authentication). Defaults to the token endpoint URL. This is not the * `resource` of the token request. */ - audience?: string | null + audience?: string /** * The RFC 8707 resource indicator the token is requested for: the resource * server's registered identifier, not a URL to fetch. */ - resource?: string | null + resource?: string /** * The scopes requested for the token, sent as one space-delimited `scope` * parameter. A requested scope may narrow the client's registered * permission set; it can never widen it. */ - scopes?: ReadonlyArray | null - assertionLifetimeSeconds?: SafeInteger | null - refreshMarginSeconds?: SafeInteger | null - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + scopes?: ReadonlyArray + assertionLifetimeSeconds?: SafeInteger + refreshMarginSeconds?: SafeInteger + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + userAgent?: string + trustedRootCertificates?: string } /** OAuth credentials are returned to the caller. Keep them out of logs and persistence. */ @@ -98,7 +98,7 @@ export interface ExchangeContext { audience: string generation: string deadlineSeconds: SafeInteger - grantId?: string | null + grantId?: string } export type ExchangePrivateKeyJwtConfig = PrivateKeyJwtConfig & { @@ -118,10 +118,10 @@ export interface RemoteExchangeSource { bootstrap: ExchangePrivateKeyJwtConfig bootstrapResource: string bootstrapScope: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + userAgent?: string + trustedRootCertificates?: string } export type ExchangeAuthorizationConfig = @@ -135,12 +135,12 @@ export type BaseRegistryAuthorization = export interface BaseRegistryClientConfig { baseUrl: string - authorization?: BaseRegistryAuthorization | null - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + authorization?: BaseRegistryAuthorization + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + trustedRootCertificates?: string /** * How many times a keyed mutation (create, patch, attachment upload or * delete, lifecycle action, immediate action, batch, tombstone, statistics @@ -149,7 +149,7 @@ export interface BaseRegistryClientConfig { * number from 0 to 2, default 2; 0 sends it once. Any other value is a * `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export type RecordFormat = 'json' | 'json-ld' diff --git a/crates/registry-breg-client-node/index.d.ts b/crates/registry-breg-client-node/index.d.ts index 1faceed70e..0b0a4683e4 100644 --- a/crates/registry-breg-client-node/index.d.ts +++ b/crates/registry-breg-client-node/index.d.ts @@ -66,7 +66,7 @@ export declare class BaseRegistryClient { /** * Invoke one metadata-selected immediate action. An invocation whose * outcome is unknown is resent identically under the same key, at most - * `maxMutationRetries` times. + * `maximumMutationRetries` times. */ invokeAction(binding: BRegActionBinding, inputs: any, idempotencyKey: string, conditions?: BRegActionTargetConditions | undefined | null): Promise /** @@ -83,7 +83,7 @@ export declare class BaseRegistryClient { /** * Explicitly send a recovered invocation under its original key. An * invocation whose outcome is unknown is resent identically under that - * key, at most `maxMutationRetries` times. + * key, at most `maximumMutationRetries` times. */ executeRecoveredAction(binding: BRegActionBinding, recovered: BRegRecoveredAction): Promise /** Execute a recovered proposal start with its exact original request and key. */ @@ -99,7 +99,7 @@ export declare class BaseRegistryClient { /** * Execute one metadata-selected atomic batch. A batch whose outcome is * unknown is resent identically under the same key, at most - * `maxMutationRetries` times. + * `maximumMutationRetries` times. */ batchRecords(binding: BRegBatchBinding, request: any, idempotencyKey: string): Promise /** diff --git a/crates/registry-breg-client-node/src/lib.rs b/crates/registry-breg-client-node/src/lib.rs index 145e7d37b0..9db2710e87 100644 --- a/crates/registry-breg-client-node/src/lib.rs +++ b/crates/registry-breg-client-node/src/lib.rs @@ -472,6 +472,21 @@ fn only_fields( Ok(()) } +fn refuse_null_fields( + object: &Map, + fields: &[&str], + kind: &'static str, + message: &'static str, +) -> Result<()> { + if fields + .iter() + .any(|field| matches!(object.get(*field), Some(Value::Null))) + { + return Err(binding_error(kind, message)); + } + Ok(()) +} + fn required_string( object: &Map, field: &str, @@ -595,7 +610,7 @@ fn private_key_jwt(value: &Value) -> Result { "scopes", "assertionLifetimeSeconds", "refreshMarginSeconds", - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "connectTimeoutMilliseconds", "userAgent", "trustedRootCertificates", @@ -603,6 +618,22 @@ fn private_key_jwt(value: &Value) -> Result { "configuration", "authorization.privateKeyJwt contains an unsupported field", )?; + refuse_null_fields( + object, + &[ + "audience", + "resource", + "scopes", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "authorization.privateKeyJwt options must be omitted instead of null", + )?; let endpoint = required_string( object, "tokenEndpoint", @@ -673,11 +704,11 @@ fn private_key_jwt(value: &Value) -> Result { } if let Some(value) = optional_u64( object, - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "configuration", - "private-key JWT request timeout must be a non-negative integer", + "private-key JWT attempt timeout must be a non-negative integer", )? { - config = config.with_request_timeout(Duration::from_millis(value)); + config = config.with_attempt_timeout(Duration::from_millis(value)); } if let Some(value) = optional_u64( object, @@ -752,6 +783,199 @@ fn bearer_value(token: registry_breg_client::BearerToken) -> Result { .ok_or_else(|| binding_error("token", "the issued credential is invalid")) } +fn validate_exchange_client(value: &Value, what: &'static str) -> Result<()> { + let object = required_object(value, what)?; + only_fields( + object, + &[ + "tokenEndpoint", + "clientId", + "clientKey", + "audience", + "resource", + "scopes", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "exchange client contains an unsupported field", + )?; + refuse_null_fields( + object, + &[ + "audience", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "exchange client options must be omitted instead of null", + )?; + optional_i64( + object, + "assertionLifetimeSeconds", + "exchange assertionLifetimeSeconds must be a safe integer", + )?; + optional_i64( + object, + "refreshMarginSeconds", + "exchange refreshMarginSeconds must be a safe integer", + )?; + optional_u64( + object, + "attemptTimeoutMilliseconds", + "configuration", + "exchange attemptTimeoutMilliseconds must be a non-negative safe integer", + )?; + optional_u64( + object, + "connectTimeoutMilliseconds", + "configuration", + "exchange connectTimeoutMilliseconds must be a non-negative safe integer", + )?; + Ok(()) +} + +fn validate_exchange_config(value: &Value) -> Result<()> { + let object = required_object(value, "authorization.exchange must be an object")?; + only_fields( + object, + &["client", "context", "firstParty", "remote"], + "configuration", + "authorization.exchange contains an unsupported field", + )?; + refuse_null_fields( + object, + &["firstParty", "remote"], + "configuration", + "authorization.exchange options must be omitted instead of null", + )?; + + validate_exchange_client( + object.get("client").ok_or_else(|| { + binding_error( + "configuration", + "authorization.exchange.client must be present", + ) + })?, + "authorization.exchange.client must be an object", + )?; + + let context = required_object( + object.get("context").ok_or_else(|| { + binding_error( + "configuration", + "authorization.exchange.context must be present", + ) + })?, + "authorization.exchange.context must be an object", + )?; + only_fields( + context, + &[ + "issuer", + "subject", + "audience", + "generation", + "deadlineSeconds", + "grantId", + ], + "configuration", + "authorization.exchange.context contains an unsupported field", + )?; + refuse_null_fields( + context, + &["grantId"], + "configuration", + "authorization.exchange.context options must be omitted instead of null", + )?; + let deadline = context.get("deadlineSeconds").ok_or_else(|| { + binding_error( + "configuration", + "authorization.exchange.context.deadlineSeconds must be present", + ) + })?; + safe_integer( + deadline, + 0, + MAXIMUM_JAVASCRIPT_SAFE_INTEGER, + "configuration", + "authorization.exchange.context.deadlineSeconds must be a non-negative safe integer", + )?; + + if let Some(first_party) = object.get("firstParty") { + let first_party = required_object( + first_party, + "authorization.exchange.firstParty must be an object", + )?; + only_fields( + first_party, + &["key", "attributes"], + "configuration", + "authorization.exchange.firstParty contains an unsupported field", + )?; + } + + if let Some(remote) = object.get("remote") { + let remote = required_object(remote, "authorization.exchange.remote must be an object")?; + only_fields( + remote, + &[ + "endpoint", + "bootstrap", + "bootstrapResource", + "bootstrapScope", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "authorization.exchange.remote contains an unsupported field", + )?; + refuse_null_fields( + remote, + &[ + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "authorization.exchange.remote options must be omitted instead of null", + )?; + validate_exchange_client( + remote.get("bootstrap").ok_or_else(|| { + binding_error( + "configuration", + "authorization.exchange.remote.bootstrap must be present", + ) + })?, + "authorization.exchange.remote.bootstrap must be an object", + )?; + optional_u64( + remote, + "attemptTimeoutMilliseconds", + "configuration", + "remote exchange attemptTimeoutMilliseconds must be a non-negative safe integer", + )?; + optional_u64( + remote, + "connectTimeoutMilliseconds", + "configuration", + "remote exchange connectTimeoutMilliseconds must be a non-negative safe integer", + )?; + } + Ok(()) +} + fn authorization_provider(value: &Value) -> Result>> { if value.is_null() { return Ok(None); @@ -776,6 +1000,7 @@ fn authorization_provider(value: &Value) -> Result .map(|provider| Some(Arc::new(provider) as Arc)); } if let Some(value) = object.get("exchange") { + validate_exchange_config(value)?; return exchange_authorization_from_json(value) .map(|provider| Some(Arc::new(provider) as Arc)) .map_err(|error| mapped_error(token_error_value(error))); @@ -793,16 +1018,30 @@ fn client_from_config(value: Value) -> Result { &[ "baseUrl", "authorization", - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "connectTimeoutMilliseconds", - "maxResponseBytes", + "maximumResponseBytes", "userAgent", "trustedRootCertificates", - "maxMutationRetries", + "maximumMutationRetries", ], "configuration", "client configuration contains an unsupported field", )?; + refuse_null_fields( + object, + &[ + "authorization", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "maximumResponseBytes", + "userAgent", + "trustedRootCertificates", + "maximumMutationRetries", + ], + "configuration", + "client configuration options must be omitted instead of null", + )?; let base_url = required_string( object, "baseUrl", @@ -819,11 +1058,11 @@ fn client_from_config(value: Value) -> Result { } if let Some(value) = optional_u64( object, - "requestTimeoutMilliseconds", + "attemptTimeoutMilliseconds", "configuration", - "requestTimeoutMilliseconds must be a non-negative integer", + "attemptTimeoutMilliseconds must be a non-negative integer", )? { - config = config.with_request_timeout(Duration::from_millis(value)); + config = config.with_attempt_timeout(Duration::from_millis(value)); } if let Some(value) = optional_u64( object, @@ -835,27 +1074,27 @@ fn client_from_config(value: Value) -> Result { } if let Some(value) = optional_u64( object, - "maxResponseBytes", + "maximumResponseBytes", "configuration", - "maxResponseBytes must be a non-negative integer", + "maximumResponseBytes must be a non-negative integer", )? { - config = config.with_max_response_bytes(value); + config = config.with_maximum_response_bytes(value); } if let Some(value) = optional_u64( object, - "maxMutationRetries", + "maximumMutationRetries", "configuration", - "maxMutationRetries must be a whole number from 0 to 2", + "maximumMutationRetries must be a whole number from 0 to 2", )? { // Only a value in the `u8` range reaches the client, which refuses one // above its own bound. let value = u8::try_from(value).map_err(|_| { binding_error( "configuration", - "maxMutationRetries must be a whole number from 0 to 2", + "maximumMutationRetries must be a whole number from 0 to 2", ) })?; - config = config.with_max_mutation_retries(value); + config = config.with_maximum_mutation_retries(value); } if let Some(value) = optional_string( object, @@ -3186,7 +3425,7 @@ impl BaseRegistryClient { /// Invoke one metadata-selected immediate action. An invocation whose /// outcome is unknown is resent identically under the same key, at most - /// `maxMutationRetries` times. + /// `maximumMutationRetries` times. #[napi] pub async fn invoke_action( &self, @@ -3252,7 +3491,7 @@ impl BaseRegistryClient { /// Explicitly send a recovered invocation under its original key. An /// invocation whose outcome is unknown is resent identically under that - /// key, at most `maxMutationRetries` times. + /// key, at most `maximumMutationRetries` times. #[napi] pub async fn execute_recovered_action( &self, @@ -3408,7 +3647,7 @@ impl BaseRegistryClient { /// Execute one metadata-selected atomic batch. A batch whose outcome is /// unknown is resent identically under the same key, at most - /// `maxMutationRetries` times. + /// `maximumMutationRetries` times. #[napi] pub async fn batch_records( &self, diff --git a/crates/registry-breg-client-py/README.md b/crates/registry-breg-client-py/README.md index 8fb0c783c9..3e349825eb 100644 --- a/crates/registry-breg-client-py/README.md +++ b/crates/registry-breg-client-py/README.md @@ -121,11 +121,18 @@ client.batch_records( All action inputs, conditions, batch fields, item counts, correction context, and byte limits are validated by the Rust client before network I/O. +Client configuration uses `maximum_response_bytes`; the retired +`max_response_bytes` spelling is refused. Passing `None` for a typed optional +constructor argument has the same effect as omitting that argument. +`attempt_timeout_seconds` bounds one outbound attempt, including its body, and +`connect_timeout_seconds` bounds connection setup. + A keyed mutation (create, patch, attachment upload or delete, Proposal transition, immediate action, batch, tombstone, statistics publish or withdraw) whose outcome is unknown is resent identically under the same key up to -`max_mutation_retries` times, a `BaseRegistryClient` keyword: 0 to 2, default -2, and 0 sends it once. The outcome is unknown after a timeout or broken +`maximum_mutation_retries` additional times, a `BaseRegistryClient` keyword: 0 +to 2, default 2, and 0 sends it once, while the default permits up to three +total attempts. The outcome is unknown after a timeout or broken exchange once the request was sent, an unusable answer, or a 5xx other than `action.handler_failed` and `statistical_dataset.domain_violation`, which the engine returns only with the attempt rolled back. When the returned diff --git a/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi b/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi index 21e03d061b..b844654bc8 100644 --- a/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi +++ b/crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi @@ -48,7 +48,7 @@ class ExchangeKeyClient(TypedDict): audience: NotRequired[str | None] assertion_lifetime_seconds: NotRequired[int | None] refresh_margin_seconds: NotRequired[int | None] - request_timeout_seconds: NotRequired[float | None] + attempt_timeout_seconds: NotRequired[float | None] connect_timeout_seconds: NotRequired[float | None] user_agent: NotRequired[str | None] trusted_root_certificates: NotRequired[str | None] @@ -62,7 +62,7 @@ class RemoteExchangeSource(TypedDict): bootstrap: ExchangeKeyClient bootstrap_resource: str bootstrap_scope: str - request_timeout_seconds: NotRequired[float | None] + attempt_timeout_seconds: NotRequired[float | None] connect_timeout_seconds: NotRequired[float | None] user_agent: NotRequired[str | None] trusted_root_certificates: NotRequired[str | None] @@ -302,12 +302,12 @@ class BaseRegistryClient: self, base_url: str, authorization: dict[str, Any] | None = None, - request_timeout_seconds: float | None = None, + attempt_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, user_agent: str | None = None, - max_response_bytes: int | None = None, + maximum_response_bytes: int | None = None, trusted_root_certificates: bytes | None = None, - max_mutation_retries: int | None = None, + maximum_mutation_retries: int | None = None, ) -> None: ... def health(self) -> dict[str, Any]: ... def ready(self) -> dict[str, Any]: ... diff --git a/crates/registry-breg-client-py/src/convert.rs b/crates/registry-breg-client-py/src/convert.rs index 1196506f7f..a1ff86e839 100644 --- a/crates/registry-breg-client-py/src/convert.rs +++ b/crates/registry-breg-client-py/src/convert.rs @@ -409,7 +409,7 @@ pub(crate) fn private_key_jwt( "scopes", "assertion_lifetime_seconds", "refresh_margin_seconds", - "request_timeout_seconds", + "attempt_timeout_seconds", "connect_timeout_seconds", "user_agent", ], @@ -442,8 +442,8 @@ pub(crate) fn private_key_jwt( if let Some(value) = optional_i64(value, "refresh_margin_seconds", WHAT)? { config = config.with_refresh_margin_seconds(value); } - if let Some(value) = optional_f64(value, "request_timeout_seconds", WHAT)? { - config = config.with_request_timeout(duration(value, "private-key JWT request timeout")?); + if let Some(value) = optional_f64(value, "attempt_timeout_seconds", WHAT)? { + config = config.with_attempt_timeout(duration(value, "private-key JWT attempt timeout")?); } if let Some(value) = optional_f64(value, "connect_timeout_seconds", WHAT)? { config = config.with_connect_timeout(duration(value, "private-key JWT connect timeout")?); @@ -497,10 +497,10 @@ pub fn config_from_parts( base_url: &str, authorization: &Value, roots: Option>, - request_timeout_seconds: Option, - connect_timeout_seconds: Option, + request_timeout: Option, + connect_timeout: Option, user_agent: Option, - max_response_bytes: Option, + maximum_response_bytes: Option, trusted_root_certificates: Option>, ) -> Result { let base_url = @@ -509,17 +509,17 @@ pub fn config_from_parts( if let Some(provider) = authorization_provider(authorization, roots)? { config = config.with_token_provider(provider); } - if let Some(value) = request_timeout_seconds { - config = config.with_request_timeout(duration(value, "request_timeout_seconds")?); + if let Some(value) = request_timeout { + config = config.with_attempt_timeout(value); } - if let Some(value) = connect_timeout_seconds { - config = config.with_connect_timeout(duration(value, "connect_timeout_seconds")?); + if let Some(value) = connect_timeout { + config = config.with_connect_timeout(value); } if let Some(value) = user_agent { config = config.with_user_agent(value); } - if let Some(value) = max_response_bytes { - config = config.with_max_response_bytes(value); + if let Some(value) = maximum_response_bytes { + config = config.with_maximum_response_bytes(value); } if let Some(value) = trusted_root_certificates { config = config.with_trusted_root_certificates(value); diff --git a/crates/registry-breg-client-py/src/lib.rs b/crates/registry-breg-client-py/src/lib.rs index 88195c8038..c607c18046 100644 --- a/crates/registry-breg-client-py/src/lib.rs +++ b/crates/registry-breg-client-py/src/lib.rs @@ -3,7 +3,7 @@ #![deny(unsafe_code)] -use std::{collections::BTreeMap, sync::Arc}; +use std::{collections::BTreeMap, sync::Arc, time::Duration}; use breg_client_sdk::{ verify_webhook_delivery as verify_core_webhook_delivery, BRegActionAvailabilityRequest, @@ -30,7 +30,7 @@ use breg_client_sdk::{ use pyo3::{ exceptions::{PyException, PyRuntimeError}, prelude::*, - types::{PyBool, PyBytes, PyDict, PyString}, + types::{PyBool, PyBytes, PyDict, PyFloat, PyInt, PyString}, }; use serde::{de::DeserializeOwned, Serialize}; use serde_json::{json, Value}; @@ -235,7 +235,7 @@ fn mutation_retries(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { py, MappedError::binding( "configuration", - "max_mutation_retries must be a whole number from 0 to 2", + "maximum_mutation_retries must be a whole number from 0 to 2", ), ) }; @@ -245,6 +245,46 @@ fn mutation_retries(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { value.extract::().map_err(|_| invalid()) } +fn configuration_error(py: Python<'_>, message: &'static str) -> PyErr { + to_py_err(py, MappedError::binding("configuration", message)) +} + +fn timeout_duration(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + if value.is_exact_instance_of::() { + return value + .extract::() + .ok() + .and_then(|seconds| Duration::try_from_secs_f64(seconds).ok()) + .ok_or_else(|| { + configuration_error(py, "client timeouts must be finite non-negative seconds") + }); + } + if value.is_exact_instance_of::() { + return value + .extract::() + .map(Duration::from_secs) + .map_err(|_| { + configuration_error(py, "client timeouts must be finite non-negative seconds") + }); + } + Err(configuration_error( + py, + "client timeouts must be finite non-negative seconds", + )) +} + +fn parse_maximum_response_bytes(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + if !value.is_exact_instance_of::() { + return Err(configuration_error( + py, + "maximum_response_bytes must be a non-negative integer", + )); + } + value.extract::().map_err(|_| { + configuration_error(py, "maximum_response_bytes must be a non-negative integer") + }) +} + fn record_format(py: Python<'_>, value: &str) -> PyResult { match value { "json" => Ok(BRegRecordFormat::Json), @@ -1640,18 +1680,18 @@ struct BaseRegistryClient { #[pymethods] impl BaseRegistryClient { #[new] - #[pyo3(signature = (base_url, authorization=None, request_timeout_seconds=None, connect_timeout_seconds=None, user_agent=None, max_response_bytes=None, trusted_root_certificates=None, max_mutation_retries=None))] + #[pyo3(signature = (base_url, authorization=None, attempt_timeout_seconds=None, connect_timeout_seconds=None, user_agent=None, maximum_response_bytes=None, trusted_root_certificates=None, maximum_mutation_retries=None))] #[allow(clippy::too_many_arguments)] fn new( py: Python<'_>, base_url: &str, authorization: Option<&Bound<'_, PyAny>>, - request_timeout_seconds: Option, - connect_timeout_seconds: Option, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, + connect_timeout_seconds: Option<&Bound<'_, PyAny>>, user_agent: Option, - max_response_bytes: Option, + maximum_response_bytes: Option<&Bound<'_, PyAny>>, trusted_root_certificates: Option>, - max_mutation_retries: Option<&Bound<'_, PyAny>>, + maximum_mutation_retries: Option<&Bound<'_, PyAny>>, ) -> PyResult { let (authorization, private_roots) = authorization_from_python(authorization) .map_err(|error| conversion_error(py, "configuration", error))?; @@ -1659,15 +1699,21 @@ impl BaseRegistryClient { base_url, &authorization, private_roots, - request_timeout_seconds, - connect_timeout_seconds, + attempt_timeout_seconds + .map(|value| timeout_duration(py, value)) + .transpose()?, + connect_timeout_seconds + .map(|value| timeout_duration(py, value)) + .transpose()?, user_agent, - max_response_bytes, + maximum_response_bytes + .map(|value| parse_maximum_response_bytes(py, value)) + .transpose()?, trusted_root_certificates, ) .map_err(|error| config_error(py, error))?; - let config = match max_mutation_retries { - Some(value) => config.with_max_mutation_retries(mutation_retries(py, value)?), + let config = match maximum_mutation_retries { + Some(value) => config.with_maximum_mutation_retries(mutation_retries(py, value)?), None => config, }; let inner = py diff --git a/crates/registry-breg-client-py/tests/python/test_construction.py b/crates/registry-breg-client-py/tests/python/test_construction.py index 9c746fc599..0b08d49e78 100644 --- a/crates/registry-breg-client-py/tests/python/test_construction.py +++ b/crates/registry-breg-client-py/tests/python/test_construction.py @@ -15,7 +15,12 @@ class ConstructionTests(unittest.TestCase): def test_constructs_with_a_deployment_base(self) -> None: self.assertIsInstance( - BaseRegistryClient("https://registry.example.invalid/tenant"), + BaseRegistryClient( + "https://registry.example.invalid/tenant", + attempt_timeout_seconds=5, + connect_timeout_seconds=2, + maximum_mutation_retries=1, + ), BaseRegistryClient, ) @@ -24,6 +29,37 @@ def test_invalid_configuration_has_a_stable_kind(self) -> None: BaseRegistryClient("not a URL") self.assertEqual(raised.exception.kind, "configuration") + def test_none_means_omission_for_optional_configuration(self) -> None: + client = BaseRegistryClient( + "https://registry.example.invalid/tenant", + attempt_timeout_seconds=None, + connect_timeout_seconds=None, + user_agent=None, + maximum_response_bytes=None, + trusted_root_certificates=None, + maximum_mutation_retries=None, + ) + self.assertIsInstance(client, BaseRegistryClient) + + def test_numeric_configuration_rejects_booleans_and_overflow(self) -> None: + for options in ( + {"attempt_timeout_seconds": True}, + {"connect_timeout_seconds": False}, + {"maximum_response_bytes": True}, + {"maximum_response_bytes": 2**64}, + ): + with self.subTest(options=options), self.assertRaises(BaseRegistryClientError) as raised: + BaseRegistryClient("https://registry.example.invalid/tenant", **options) + self.assertEqual(raised.exception.kind, "configuration") + + def test_retired_top_level_option_names_are_rejected(self) -> None: + for options in ( + {"request_timeout_seconds": 30}, + {"max_mutation_retries": 1}, + ): + with self.subTest(options=options), self.assertRaises(TypeError): + BaseRegistryClient("https://registry.example.invalid/tenant", **options) + def test_unknown_authorization_shape_is_rejected(self) -> None: with self.assertRaises(BaseRegistryClientError) as raised: BaseRegistryClient( diff --git a/crates/registry-breg-client-py/tests/python/test_mutation_parity.py b/crates/registry-breg-client-py/tests/python/test_mutation_parity.py index 3883539573..948f257768 100644 --- a/crates/registry-breg-client-py/tests/python/test_mutation_parity.py +++ b/crates/registry-breg-client-py/tests/python/test_mutation_parity.py @@ -564,7 +564,7 @@ def test_immediate_action_application_identity_preserves_nil_record_ids(self) -> def test_immediate_action_recovers_after_a_lost_response(self) -> None: # The process gives up after the lost answer instead of resending it. self.client = BaseRegistryClient( - f"http://127.0.0.1:{self.server.server_port}", max_mutation_retries=0 + f"http://127.0.0.1:{self.server.server_port}", maximum_mutation_retries=0 ) binding = self.contract.select_action( "rename-company", "company-writer" diff --git a/crates/registry-breg-client-py/tests/python/test_mutation_retry.py b/crates/registry-breg-client-py/tests/python/test_mutation_retry.py index 65a6a0dabb..dfb77f3f38 100644 --- a/crates/registry-breg-client-py/tests/python/test_mutation_retry.py +++ b/crates/registry-breg-client-py/tests/python/test_mutation_retry.py @@ -120,7 +120,7 @@ def test_a_503_is_resent_once_under_the_same_key_and_succeeds(self) -> None: def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> None: self.script(UNAVAILABLE, PUBLICATION) - client = BaseRegistryClient(self.base_url, max_mutation_retries=0) + client = BaseRegistryClient(self.base_url, maximum_mutation_retries=0) with self.assertRaises(BaseRegistryClientError) as raised: self.publish(client) @@ -135,7 +135,7 @@ def test_a_refusal_or_a_failure_before_commit_is_never_resent(self) -> None: for refusal in (VERSION_CONFLICT, DOMAIN_VIOLATION): with self.subTest(status=refusal[0]): self.script(refusal, PUBLICATION) - client = BaseRegistryClient(self.base_url, max_mutation_retries=2) + client = BaseRegistryClient(self.base_url, maximum_mutation_retries=2) with self.assertRaises(BaseRegistryClientError) as raised: self.publish(client) @@ -148,11 +148,11 @@ def test_a_refusal_or_a_failure_before_commit_is_never_resent(self) -> None: def test_a_ceiling_outside_zero_to_two_is_a_configuration_error(self) -> None: for retries in (0, 1, 2): with self.subTest(retries=retries): - BaseRegistryClient(self.base_url, max_mutation_retries=retries) + BaseRegistryClient(self.base_url, maximum_mutation_retries=retries) for retries in (3, 255, 256, -1, 1.5, "1", True): with self.subTest(retries=retries): with self.assertRaises(BaseRegistryClientError) as raised: - BaseRegistryClient(self.base_url, max_mutation_retries=retries) + BaseRegistryClient(self.base_url, maximum_mutation_retries=retries) self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) diff --git a/crates/registry-breg-client/README.md b/crates/registry-breg-client/README.md index decce5d043..1a0db46e93 100644 --- a/crates/registry-breg-client/README.md +++ b/crates/registry-breg-client/README.md @@ -194,10 +194,10 @@ statistical publication and withdrawal) whose outcome is unknown, with the same key, headers, and body bytes, at most twice by default, waiting 250 ms and then 500 ms, or the service's `Retry-After` when that is longer and at most 5 seconds. A longer requested wait ends the retries. Configure the count -with `BaseRegistryClientConfig::with_max_mutation_retries` (0 to 2; 0 disables +with `BaseRegistryClientConfig::with_maximum_mutation_retries` (0 to 2; 0 disables the resend). Reads, ingestion run calls, and action target-condition requests -are never resent. Each attempt has the full request timeout, so a call can -take up to three request timeouts plus the waits. +are never resent. Each attempt has the full attempt timeout, so a call can +take up to three attempt timeouts plus the waits. BReg holds a response under the key only for a committed success, so a resend after a 5xx either replays that success or runs the operation again. diff --git a/crates/registry-breg-client/src/client.rs b/crates/registry-breg-client/src/client.rs index 068ecda79f..1bb5874bf5 100644 --- a/crates/registry-breg-client/src/client.rs +++ b/crates/registry-breg-client/src/client.rs @@ -977,7 +977,10 @@ impl BaseRegistryClient { } let body = self .transport - .read(response, maximum_bytes.min(self.config.max_response_bytes)) + .read( + response, + maximum_bytes.min(self.config.maximum_response_bytes), + ) .await?; Ok(BRegComplete { value: BRegRawDocument::new(media_type, body), @@ -1447,7 +1450,7 @@ impl BaseRegistryClient { } let body = self .transport - .read(response, self.config.max_response_bytes) + .read(response, self.config.maximum_response_bytes) .await?; crate::strict_json::from_slice(&body) .map_err(|_| body_failure(status.as_u16(), trace.clone()))?; @@ -1574,7 +1577,7 @@ impl BaseRegistryClient { let engine_version = breg_engine_version(&headers); let body = self .transport - .read(response, self.config.max_response_bytes) + .read(response, self.config.maximum_response_bytes) .await?; let representation_digest = match digest { Some((value, expected)) if expected == Sha256::digest(&body).as_slice() => { @@ -1750,7 +1753,7 @@ impl BaseRegistryClient { /// Send one idempotency-keyed mutation and decode its answer, resending /// the identical request under the same key while its outcome stays - /// unknown, at most `max_mutation_retries` times. The credential is + /// unknown, at most `maximum_mutation_retries` times. The credential is /// acquired once, so every attempt carries the same headers and body bytes. async fn send_keyed( &self, @@ -1763,7 +1766,7 @@ impl BaseRegistryClient { { let builder = self.authorize(builder, Credential::Optional).await?; let (builder, decode) = (&builder, &decode); - retry_keyed_mutation(self.config.max_mutation_retries, || async move { + retry_keyed_mutation(self.config.maximum_mutation_retries, || async move { let Some(attempt) = builder.try_clone() else { return KeyedMutationAttempt::Settled(Err( BaseRegistryClientError::invalid_request( @@ -1809,7 +1812,7 @@ impl BaseRegistryClient { response, expected_media, etag_expectation, - self.config.max_response_bytes, + self.config.maximum_response_bytes, ) .await } @@ -1853,7 +1856,10 @@ impl BaseRegistryClient { let engine_version = breg_engine_version(&headers); let body = self .transport - .read(response, self.config.max_response_bytes.min(maximum_bytes)) + .read( + response, + self.config.maximum_response_bytes.min(maximum_bytes), + ) .await?; Ok(BRegWire { body, @@ -1909,7 +1915,7 @@ impl BaseRegistryClient { } let body = self .transport - .read(response, self.config.max_response_bytes) + .read(response, self.config.maximum_response_bytes) .await?; Ok(BRegWire { body, @@ -1966,7 +1972,7 @@ impl BaseRegistryClient { } let body = self .transport - .read(response, self.config.max_response_bytes) + .read(response, self.config.maximum_response_bytes) .await?; let mut metadata = BRegResponseMetadata::new(trace_id, Some(etag)); if let Some(location) = location { diff --git a/crates/registry-breg-client/src/config.rs b/crates/registry-breg-client/src/config.rs index 451824a20e..77bb5808c2 100644 --- a/crates/registry-breg-client/src/config.rs +++ b/crates/registry-breg-client/src/config.rs @@ -7,8 +7,8 @@ use url::Url; use zeroize::Zeroizing; use crate::{ - BaseRegistryClientError, DEFAULT_CONNECT_TIMEOUT, DEFAULT_MAX_RESPONSE_BYTES, - DEFAULT_REQUEST_TIMEOUT, + BaseRegistryClientError, DEFAULT_ATTEMPT_TIMEOUT, DEFAULT_CONNECT_TIMEOUT, + DEFAULT_MAXIMUM_RESPONSE_BYTES, }; /// Transport policy for one Base Registry Engine deployment. @@ -16,12 +16,12 @@ use crate::{ pub struct BaseRegistryClientConfig { pub(crate) base_url: Url, pub(crate) token_provider: Option>, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>>, - pub(crate) max_mutation_retries: u8, + pub(crate) maximum_mutation_retries: u8, } impl BaseRegistryClientConfig { @@ -30,12 +30,12 @@ impl BaseRegistryClientConfig { Self { base_url, token_provider: None, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_ATTEMPT_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, - max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, user_agent: None, trusted_root_certificates: None, - max_mutation_retries: DEFAULT_MUTATION_RETRIES, + maximum_mutation_retries: DEFAULT_MUTATION_RETRIES, } } @@ -46,8 +46,8 @@ impl BaseRegistryClientConfig { } #[must_use] - pub fn with_request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; + pub fn with_attempt_timeout(mut self, timeout: Duration) -> Self { + self.attempt_timeout = timeout; self } @@ -58,8 +58,8 @@ impl BaseRegistryClientConfig { } #[must_use] - pub fn with_max_response_bytes(mut self, maximum: u64) -> Self { - self.max_response_bytes = maximum; + pub fn with_maximum_response_bytes(mut self, maximum: u64) -> Self { + self.maximum_response_bytes = maximum; self } @@ -81,8 +81,8 @@ impl BaseRegistryClientConfig { /// returned. Defaults to 2, accepts at most 2, and 0 disables the /// resend. #[must_use] - pub fn with_max_mutation_retries(mut self, value: u8) -> Self { - self.max_mutation_retries = value; + pub fn with_maximum_mutation_retries(mut self, value: u8) -> Self { + self.maximum_mutation_retries = value; self } @@ -97,17 +97,17 @@ impl BaseRegistryClientConfig { "the service base URL must be an HTTPS URL, or loopback HTTP, with no credentials, query, or fragment", ) })?; - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(BaseRegistryClientError::configuration( - "request and connection timeouts must be greater than zero", + "attempt and connection timeouts must be greater than zero", )); } - if self.max_response_bytes == 0 { + if self.maximum_response_bytes == 0 { return Err(BaseRegistryClientError::configuration( "the response body bound must be greater than zero", )); } - if self.max_mutation_retries > MAXIMUM_MUTATION_RETRIES { + if self.maximum_mutation_retries > MAXIMUM_MUTATION_RETRIES { return Err(BaseRegistryClientError::configuration( "the mutation retry count exceeds the accepted bound", )); @@ -122,11 +122,11 @@ impl fmt::Debug for BaseRegistryClientConfig { .debug_struct("BaseRegistryClientConfig") .field("base_url", &"") .field("token_provider", &self.token_provider.is_some()) - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("user_agent", &self.user_agent.is_some()) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -163,7 +163,7 @@ mod tests { fn the_mutation_retry_count_is_bounded() { let url = Url::parse("https://registry.example.invalid/").expect("fixture URL"); let error = BaseRegistryClientConfig::new(url.clone()) - .with_max_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1) + .with_maximum_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1) .validate() .expect_err("a retry count above the bound is refused"); assert!(matches!( @@ -174,13 +174,26 @@ mod tests { )); for retries in 0..=MAXIMUM_MUTATION_RETRIES { assert!(BaseRegistryClientConfig::new(url.clone()) - .with_max_mutation_retries(retries) + .with_maximum_mutation_retries(retries) .validate() .is_ok()); } assert_eq!( - BaseRegistryClientConfig::new(url).max_mutation_retries, + BaseRegistryClientConfig::new(url).maximum_mutation_retries, DEFAULT_MUTATION_RETRIES ); } + + #[test] + fn the_response_bound_keeps_its_default_and_refuses_zero() { + let url = Url::parse("https://registry.example.invalid/").expect("fixture URL"); + assert_eq!( + BaseRegistryClientConfig::new(url.clone()).maximum_response_bytes, + DEFAULT_MAXIMUM_RESPONSE_BYTES + ); + assert!(BaseRegistryClientConfig::new(url) + .with_maximum_response_bytes(0) + .validate() + .is_err()); + } } diff --git a/crates/registry-breg-client/src/lib.rs b/crates/registry-breg-client/src/lib.rs index 792b3f4ae9..147b588058 100644 --- a/crates/registry-breg-client/src/lib.rs +++ b/crates/registry-breg-client/src/lib.rs @@ -10,7 +10,7 @@ //! `statistical_dataset.domain_violation`, which the engine returns only with //! the attempt rolled back) is resent, byte for byte and under the same key //! and credential, at most -//! `BaseRegistryClientConfig::with_max_mutation_retries` times (2 by default, +//! `BaseRegistryClientConfig::with_maximum_mutation_retries` times (2 by default, //! 0 disables it). Reads and unkeyed exchanges, such as ingestion runs and //! action target conditions, are never resent. A 5xx answer may follow a //! commit: when `BaseRegistryClientError::is_outcome_unknown` is still true, @@ -71,9 +71,9 @@ pub use statistics::*; pub use uuid::Uuid; pub use webhook::*; -pub const DEFAULT_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +pub const DEFAULT_ATTEMPT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); pub const DEFAULT_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; +pub const DEFAULT_MAXIMUM_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; /// Invalid, duplicate-containing, overlarge, or numerically lossy JSON input. #[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] @@ -83,7 +83,7 @@ pub struct BRegExactJsonError; /// Decode bounded JSON without duplicate members or silently rounded literals. /// Mutation builders additionally enforce BReg's supported binary64 integer model. pub fn decode_exact_json(bytes: &[u8]) -> Result { - if bytes.len() > DEFAULT_MAX_RESPONSE_BYTES as usize { + if bytes.len() > DEFAULT_MAXIMUM_RESPONSE_BYTES as usize { return Err(BRegExactJsonError); } strict_json::from_slice(bytes).map_err(|()| BRegExactJsonError) diff --git a/crates/registry-breg-client/src/proposal.rs b/crates/registry-breg-client/src/proposal.rs index 4ce521f72a..7d75592f3b 100644 --- a/crates/registry-breg-client/src/proposal.rs +++ b/crates/registry-breg-client/src/proposal.rs @@ -415,7 +415,7 @@ impl BRegProposal { self.input.as_ref() } pub fn from_slice(bytes: &[u8]) -> Result { - if bytes.len() > DEFAULT_MAX_RESPONSE_BYTES as usize { + if bytes.len() > DEFAULT_MAXIMUM_RESPONSE_BYTES as usize { return Err(refusal()); } let value = crate::strict_json::from_slice(bytes).map_err(|_| refusal())?; @@ -896,7 +896,7 @@ pub struct BRegPreparedProposal { } impl BRegPreparedProposal { pub fn from_slice(bytes: &[u8]) -> Result { - if bytes.len() > DEFAULT_MAX_RESPONSE_BYTES as usize { + if bytes.len() > DEFAULT_MAXIMUM_RESPONSE_BYTES as usize { return Err(refusal()); } let value = crate::strict_json::from_slice(bytes).map_err(|_| refusal())?; diff --git a/crates/registry-breg-client/src/transport.rs b/crates/registry-breg-client/src/transport.rs index 23a17d4d93..b158bda52f 100644 --- a/crates/registry-breg-client/src/transport.rs +++ b/crates/registry-breg-client/src/transport.rs @@ -13,7 +13,7 @@ use crate::{BRegProtocolFailure, BaseRegistryClientConfig, BaseRegistryClientErr pub(crate) struct Transport { pub(crate) http: reqwest::Client, pub(crate) base_url: ServiceBaseUrl, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, } impl Transport { @@ -22,7 +22,7 @@ impl Transport { BaseRegistryClientError::configuration("the service base URL is not usable") })?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config @@ -34,7 +34,7 @@ impl Transport { Ok(Self { http, base_url, - max_response_bytes: config.max_response_bytes, + maximum_response_bytes: config.maximum_response_bytes, }) } @@ -67,7 +67,7 @@ impl Transport { response: Response, maximum: u64, ) -> Result, BaseRegistryClientError> { - read_bounded(response, maximum.min(self.max_response_bytes)) + read_bounded(response, maximum.min(self.maximum_response_bytes)) .await .map_err(|error| BaseRegistryClientError::transport(read_failure_kind(&error))) } diff --git a/crates/registry-breg-client/tests/http_boundary.rs b/crates/registry-breg-client/tests/http_boundary.rs index 8f20aa6e7f..2f34e24b36 100644 --- a/crates/registry-breg-client/tests/http_boundary.rs +++ b/crates/registry-breg-client/tests/http_boundary.rs @@ -458,7 +458,7 @@ async fn test_client( let mut config = BaseRegistryClientConfig::new( Url::parse(&format!("http://{address}/tenant/prefix")).expect("base URL"), ) - .with_max_response_bytes(maximum); + .with_maximum_response_bytes(maximum); if let Some(provider) = provider { config = config.with_token_provider(provider); } diff --git a/crates/registry-breg-client/tests/mutation_retry.rs b/crates/registry-breg-client/tests/mutation_retry.rs index ae397ac181..66cc4a9323 100644 --- a/crates/registry-breg-client/tests/mutation_retry.rs +++ b/crates/registry-breg-client/tests/mutation_retry.rs @@ -235,7 +235,7 @@ fn assert_identical_resends(observations: &[Captured]) { #[tokio::test] async fn a_timed_out_mutation_is_resent_identically_under_the_same_key() { let (address, script, server) = serve(&[Answer::Stall, Answer::Published]).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let published = publish(&client) .await @@ -395,7 +395,7 @@ async fn the_retry_count_is_honored() { .await; let mut config = config(&address); if let Some(retries) = retries { - config = config.with_max_mutation_retries(retries); + config = config.with_maximum_mutation_retries(retries); } let error = publish(&client(config)) .await @@ -417,8 +417,8 @@ async fn zero_retries_disables_the_resend() { let (address, script, server) = serve(&[Answer::Stall, Answer::Published]).await; let error = publish(&client( config(&address) - .with_request_timeout(Duration::from_millis(500)) - .with_max_mutation_retries(0), + .with_attempt_timeout(Duration::from_millis(500)) + .with_maximum_mutation_retries(0), )) .await .expect_err("the only attempt timed out"); @@ -569,7 +569,7 @@ async fn an_unreadable_4xx_answer_is_unknown_but_never_resent() { (Unfinished::Close, TransportKind::Exchange), ] { let (address, requests, server) = serve_unfinished_conflict(ending).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let error = publish(&client) .await .expect_err("the refusal body never arrives"); diff --git a/crates/registry-breg-client/tests/proposal_document_boundary.rs b/crates/registry-breg-client/tests/proposal_document_boundary.rs index 8bf3553905..04ed55c865 100644 --- a/crates/registry-breg-client/tests/proposal_document_boundary.rs +++ b/crates/registry-breg-client/tests/proposal_document_boundary.rs @@ -81,7 +81,7 @@ impl Fixture { let token = Arc::new(CountingToken::default()); let client = BaseRegistryClient::new( BaseRegistryClientConfig::new(format!("http://{address}/").parse().unwrap()) - .with_max_mutation_retries(0) + .with_maximum_mutation_retries(0) .with_token_provider(token.clone()), ) .unwrap(); @@ -1493,7 +1493,7 @@ async fn proposal_input_snapshot_bound_is_checked_before_protected_io() { let mut saved: Value = serde_json::from_slice(attempt.as_bytes()).unwrap(); saved["body"] = json!(json!({"input":oversized}).to_string()); let saved = serde_json::to_vec(&saved).unwrap(); - assert!(saved.len() < registry_breg_client::DEFAULT_MAX_RESPONSE_BYTES as usize); + assert!(saved.len() < registry_breg_client::DEFAULT_MAXIMUM_RESPONSE_BYTES as usize); assert!( BRegPreparedProposal::from_slice(&saved).is_err(), "{operation}:{state}:oversized restored input" diff --git a/crates/registry-breg-client/tests/read_parity.rs b/crates/registry-breg-client/tests/read_parity.rs index abfed54573..e49f283a3d 100644 --- a/crates/registry-breg-client/tests/read_parity.rs +++ b/crates/registry-breg-client/tests/read_parity.rs @@ -130,10 +130,14 @@ fn response(media: &str, value: Value, link: bool, etag: bool) -> Response } async fn client(provider: Arc) -> (BaseRegistryClient, Arc>>) { - client_with_max_response_bytes(provider, registry_breg_client::DEFAULT_MAX_RESPONSE_BYTES).await + client_with_maximum_response_bytes( + provider, + registry_breg_client::DEFAULT_MAXIMUM_RESPONSE_BYTES, + ) + .await } -async fn client_with_max_response_bytes( +async fn client_with_maximum_response_bytes( provider: Arc, maximum: u64, ) -> (BaseRegistryClient, Arc>>) { @@ -152,7 +156,7 @@ async fn client_with_max_response_bytes( let config = BaseRegistryClientConfig::new(Url::parse(&format!("http://{address}/prefix")).unwrap()) .with_token_provider(provider) - .with_max_response_bytes(maximum); + .with_maximum_response_bytes(maximum); (BaseRegistryClient::new(config).unwrap(), captured) } @@ -203,7 +207,7 @@ async fn snapshot_valid_at_uses_the_servers_canonical_utc_representation() { async fn geojson_uses_the_configured_response_body_bound() { let provider = Arc::new(CountingToken(AtomicUsize::new(0))); let high_limit = 3 * 1024 * 1024; - let (client, _) = client_with_max_response_bytes(provider.clone(), high_limit).await; + let (client, _) = client_with_maximum_response_bytes(provider.clone(), high_limit).await; let feature = client .get_geojson_record("large-geojson", RECORD_ID, &BRegGeoJsonOptions::default()) .await @@ -238,7 +242,7 @@ async fn geojson_uses_the_configured_response_body_bound() { 2 * 1024 * 1024 ); - let (client, _) = client_with_max_response_bytes(provider, 2 * 1024 * 1024).await; + let (client, _) = client_with_maximum_response_bytes(provider, 2 * 1024 * 1024).await; let error = client .get_geojson_record("large-geojson", RECORD_ID, &BRegGeoJsonOptions::default()) .await diff --git a/crates/registry-breg-client/tests/write_http_boundary.rs b/crates/registry-breg-client/tests/write_http_boundary.rs index cec76eccea..36d6fba1a9 100644 --- a/crates/registry-breg-client/tests/write_http_boundary.rs +++ b/crates/registry-breg-client/tests/write_http_boundary.rs @@ -193,7 +193,7 @@ async fn serve_test_client(responses: Vec, max_mutation_retries: u Url::parse(&format!("http://{address}/tenant/base")).expect("base URL"), ) .with_token_provider(token.clone()) - .with_max_mutation_retries(max_mutation_retries); + .with_maximum_mutation_retries(max_mutation_retries); TestClient { client: BaseRegistryClient::new(config).expect("client"), requests, diff --git a/crates/registry-breg-mcp/README.md b/crates/registry-breg-mcp/README.md index 4cab52772b..5bdd76c605 100644 --- a/crates/registry-breg-mcp/README.md +++ b/crates/registry-breg-mcp/README.md @@ -161,9 +161,11 @@ accepted, 1 when something is refused (or a warning is reported under `serve` refuses a file `check` would refuse, with the same lines on standard error, then resolves every secret before it listens, and answers until `SIGINT` or `SIGTERM`. Logs are JSON lines on standard output from the gateway -alone, at the level `BREG_MCP_LOG` names: `error`, `warn`, or `info` (the -default). Any other value, including a filter directive, is refused on -standard error with exit status 2 before any work. +alone. `BREG_MCP_LOG` selects the level when set, otherwise the runtime file's +top-level `logLevel` applies, with `info` as its default. Both sources accept +only `error`, `warn`, or `info`; any other value, including a filter directive, +is refused. `check` validates `logLevel`, reads no environment override, and +emits no operational log. The runtime file below is [`products/breg/examples/mcp-runtime/runtime.yaml`](../../products/breg/examples/mcp-runtime/runtime.yaml); diff --git a/crates/registry-breg-mcp/src/config.rs b/crates/registry-breg-mcp/src/config.rs index b85ea99cf3..9711fac2a3 100644 --- a/crates/registry-breg-mcp/src/config.rs +++ b/crates/registry-breg-mcp/src/config.rs @@ -43,6 +43,33 @@ pub const RUNTIME_KIND: &str = "BRegMcpRuntimeConfig"; /// The single path the MCP endpoint is served at. pub const MCP_PATH: &str = "/mcp"; +/// The operational log level. The vocabulary is closed so configuration +/// cannot turn on debug output or dependency logging. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + #[default] + Info, +} + +impl LogLevel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + } + } +} + +const fn default_log_level() -> LogLevel { + LogLevel::Info +} + /// The most characters a citizen-facing service text may hold. const MAXIMUM_SERVICE_TEXT_CHARS: usize = 4096; /// The most items a client, scope, or algorithm list may hold. @@ -109,6 +136,11 @@ const REMOVED_KEYS: &[RemovedKey] = &[ pub struct RuntimeConfig { pub api_version: String, pub kind: String, + /// Operational log level when `BREG_MCP_LOG` is not set. Defaults to + /// `info`; accepted values are `error`, `warn`, and `info`. + #[serde(default = "default_log_level")] + #[cfg_attr(feature = "schema", schemars(default))] + pub log_level: LogLevel, pub listener: PrivateListenerConfig, pub secret_providers: SecretProvidersConfig, /// Who may call the gateway: the chat hosts' authorization server. @@ -1115,6 +1147,7 @@ rateLimits: #[test] fn a_complete_document_loads() { let config = load(&document()).expect("document loads"); + assert_eq!(config.log_level.as_str(), "info"); assert_eq!(config.registry.access_profile.as_str(), "citizen-agent"); assert_eq!(config.limits.request_bytes(), 64 * 1024); assert_eq!(config.registry.attempt_timeout_milliseconds.get(), 10_000); @@ -1129,6 +1162,34 @@ rateLimits: )); } + #[test] + fn log_level_accepts_only_the_closed_vocabulary() { + for accepted in ["error", "warn", "info"] { + let text = document().replacen( + "kind: BRegMcpRuntimeConfig\n", + &format!("kind: BRegMcpRuntimeConfig\nlogLevel: {accepted}\n"), + 1, + ); + assert_eq!( + load(&text).expect("level loads").log_level.as_str(), + accepted + ); + } + for refused_value in ["debug", "trace", "off", "Info", ""] { + let yaml_value = if refused_value.is_empty() { + "''" + } else { + refused_value + }; + let text = document().replacen( + "kind: BRegMcpRuntimeConfig\n", + &format!("kind: BRegMcpRuntimeConfig\nlogLevel: {yaml_value}\n"), + 1, + ); + refused(&text, "config.unknown-variant", "/logLevel"); + } + } + #[test] fn the_committed_example_loads() { let example = Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/registry-breg-mcp/src/main.rs b/crates/registry-breg-mcp/src/main.rs index 8e4c38eef0..70255a5468 100644 --- a/crates/registry-breg-mcp/src/main.rs +++ b/crates/registry-breg-mcp/src/main.rs @@ -8,7 +8,7 @@ use clap::Parser; use registry_breg_mcp::{ check, cli::{Cli, Command}, - config::RuntimeConfig, + config::{LogLevel, RuntimeConfig}, runtime, }; use tracing::Level; @@ -19,9 +19,16 @@ const LOG_VARIABLE: &str = "BREG_MCP_LOG"; /// The operational log level. The vocabulary is closed so a typo cannot /// silently turn logging off or turn a dependency's logging on. -fn operational_log_level(value: Result) -> Result { +fn operational_log_level( + value: Result, + configured: LogLevel, +) -> Result { match value { - Err(std::env::VarError::NotPresent) => Ok(Level::INFO), + Err(std::env::VarError::NotPresent) => Ok(match configured { + LogLevel::Error => Level::ERROR, + LogLevel::Warn => Level::WARN, + LogLevel::Info => Level::INFO, + }), Ok(value) => match value.as_str() { "error" => Ok(Level::ERROR), "warn" => Ok(Level::WARN), @@ -54,13 +61,17 @@ fn main() -> ExitCode { )); } - let level = match operational_log_level(std::env::var(LOG_VARIABLE)) { - Ok(level) => level, - Err(error) => { - eprintln!("breg-mcp: {error}"); - return ExitCode::from(2); - } + let environment_level = match std::env::var(LOG_VARIABLE) { + Err(std::env::VarError::NotPresent) => None, + value => match operational_log_level(value, LogLevel::Info) { + Ok(level) => Some(level), + Err(error) => { + eprintln!("breg-mcp: {error}"); + return ExitCode::from(2); + } + }, }; + // A refused runtime file is reported in the shared human shape on // standard error (CFG-DIAG-2), naming members and never their values. let config = match RuntimeConfig::load(&cli.runtime_config) { @@ -70,6 +81,10 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; + let level = environment_level.unwrap_or_else(|| { + operational_log_level(Err(std::env::VarError::NotPresent), config.log_level) + .expect("the runtime log level was validated") + }); // JSON lines on standard output, from this crate only. tracing_subscriber::registry() .with( @@ -134,16 +149,29 @@ mod tests { #[test] fn the_log_level_vocabulary_is_closed() { + use registry_breg_mcp::config::LogLevel; + assert_eq!( - operational_log_level(Err(std::env::VarError::NotPresent)), + operational_log_level(Err(std::env::VarError::NotPresent), LogLevel::Info), Ok(Level::INFO) ); + assert_eq!( + operational_log_level(Err(std::env::VarError::NotPresent), LogLevel::Warn), + Ok(Level::WARN) + ); + assert_eq!( + operational_log_level(Ok("error".to_owned()), LogLevel::Warn), + Ok(Level::ERROR) + ); for (value, level) in [ ("error", Level::ERROR), ("warn", Level::WARN), ("info", Level::INFO), ] { - assert_eq!(operational_log_level(Ok(value.to_owned())), Ok(level)); + assert_eq!( + operational_log_level(Ok(value.to_owned()), LogLevel::Info), + Ok(level) + ); } for refused in [ "debug", @@ -154,7 +182,7 @@ mod tests { "registry_breg_mcp=info", ] { assert_eq!( - operational_log_level(Ok(refused.to_owned())), + operational_log_level(Ok(refused.to_owned()), LogLevel::Info), Err("BREG_MCP_LOG must be one of error, warn, or info".to_owned()), "{refused:?}" ); diff --git a/crates/registry-breg-mcp/src/outbound.rs b/crates/registry-breg-mcp/src/outbound.rs index a89dad0db3..4d2ad8b95f 100644 --- a/crates/registry-breg-mcp/src/outbound.rs +++ b/crates/registry-breg-mcp/src/outbound.rs @@ -156,12 +156,12 @@ impl Outbound { BaseRegistryClientConfig::new(self.base_url.clone()), authorization, ) - .with_request_timeout(self.timeout) + .with_attempt_timeout(self.timeout) // The gateway owns same-key recovery: its keys are derived // deterministically and the chat host retries a call whose outcome // is unknown. Client resends stacked under that would multiply // the wait of an interactive turn. - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .map_err(|_| TokenError::Configuration { reason: "the registry client could not be configured", @@ -191,7 +191,7 @@ fn client_config( timeout: Duration, ) -> PrivateKeyJwtConfig { let config = PrivateKeyJwtConfig::new(token_endpoint.clone(), client_id, key.clone()) - .with_request_timeout(timeout); + .with_attempt_timeout(timeout); match assertion_audience { Some(audience) => config.with_audience(audience), None => config, diff --git a/crates/registry-breg-review/README.md b/crates/registry-breg-review/README.md index 2faf021f32..68e85fd7f3 100644 --- a/crates/registry-breg-review/README.md +++ b/crates/registry-breg-review/README.md @@ -89,13 +89,14 @@ block must stay literal. The redirect URI registered with the provider is loopback listener only: it drops `Secure`, the `__Host-` cookie prefix, and HSTS, and relaxes the provider endpoint policy to loopback HTTP. -`BREG_REVIEW_LOG` selects the operational log level: `error`, `warn`, or -`info` (the default). Any other value stops startup with exit status 2, so a -typo cannot silently change what is logged. `check` reads no level. The -operational log is JSON on standard output. A refused runtime file is -reported on standard error in the same lines `check` prints, and any other -startup refusal is one line on standard error; either way the process exits -with status 1. +`BREG_REVIEW_LOG` selects the operational log level when set, otherwise the +runtime file's top-level `logLevel` applies, with `info` as its default. Both +sources accept only `error`, `warn`, or `info`; any other value refuses startup +instead of falling back. `check` validates `logLevel`, reads no environment +override, and emits no operational log. The operational log is JSON on standard +output. A refused runtime file is reported on standard error in the same lines +`check` prints, and any other startup refusal is one line on standard error; +either way the process exits with status 1. ## Sign-in diff --git a/crates/registry-breg-review/src/config.rs b/crates/registry-breg-review/src/config.rs index 0faec6e7f5..8e5f7239b4 100644 --- a/crates/registry-breg-review/src/config.rs +++ b/crates/registry-breg-review/src/config.rs @@ -37,6 +37,33 @@ pub const RUNTIME_API_VERSION: &str = "id.registrystack.org/formats/breg/review- /// Kind of the review page runtime configuration document. pub const RUNTIME_KIND: &str = "BRegReviewRuntimeConfig"; +/// The operational log level. The vocabulary is closed so configuration +/// cannot turn on debug output or dependency logging. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + #[default] + Info, +} + +impl LogLevel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + } + } +} + +const fn default_log_level() -> LogLevel { + LogLevel::Info +} + /// The path the authorization server returns a person to after sign-in, /// relative to `publicOrigin`. pub const CALLBACK_PATH: &str = "/signin/callback"; @@ -92,6 +119,11 @@ const REMOVED_KEYS: &[RemovedKey] = &[ pub struct RuntimeConfig { pub api_version: String, pub kind: String, + /// Operational log level when `BREG_REVIEW_LOG` is not set. Defaults to + /// `info`; accepted values are `error`, `warn`, and `info`. + #[serde(default = "default_log_level")] + #[cfg_attr(feature = "schema", schemars(default))] + pub log_level: LogLevel, pub listener: PrivateListenerConfig, /// The browser-facing origin, such as `https://review.example`: an https /// URL with no path, query, or fragment; plain http is accepted only on @@ -961,6 +993,7 @@ audit: #[test] fn a_production_document_loads_with_its_defaults() { let config = load(&document()).expect("valid document"); + assert_eq!(config.log_level.as_str(), "info"); assert!(!config.development()); assert_eq!( config.redirect_uri().as_str(), @@ -979,6 +1012,34 @@ audit: assert_eq!(config.registry.target_input.as_str(), "address"); } + #[test] + fn log_level_accepts_only_the_closed_vocabulary() { + for accepted in ["error", "warn", "info"] { + let text = document().replacen( + "kind: BRegReviewRuntimeConfig\n", + &format!("kind: BRegReviewRuntimeConfig\nlogLevel: {accepted}\n"), + 1, + ); + assert_eq!( + load(&text).expect("level loads").log_level.as_str(), + accepted + ); + } + for refused_value in ["debug", "trace", "off", "Info", ""] { + let yaml_value = if refused_value.is_empty() { + "''" + } else { + refused_value + }; + let text = document().replacen( + "kind: BRegReviewRuntimeConfig\n", + &format!("kind: BRegReviewRuntimeConfig\nlogLevel: {yaml_value}\n"), + 1, + ); + refused(&text, "config.unknown-variant", "/logLevel"); + } + } + #[test] fn the_committed_example_loads() { let example = Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/registry-breg-review/src/lib.rs b/crates/registry-breg-review/src/lib.rs index 6f042398f7..14eccf5fb4 100644 --- a/crates/registry-breg-review/src/lib.rs +++ b/crates/registry-breg-review/src/lib.rs @@ -26,7 +26,7 @@ mod signin; mod templates; pub use config::{ - check_runtime, RuntimeCheck, RuntimeConfig, RuntimeConfigError, RUNTIME_API_VERSION, + check_runtime, LogLevel, RuntimeCheck, RuntimeConfig, RuntimeConfigError, RUNTIME_API_VERSION, RUNTIME_KIND, }; @@ -588,7 +588,7 @@ fn offline(config: &RuntimeConfig) -> Result { // Client resends stacked under each submit would multiply the wait of a // person looking at the page. let registry = BaseRegistryClient::new( - BaseRegistryClientConfig::new(base_url).with_max_mutation_retries(0), + BaseRegistryClientConfig::new(base_url).with_maximum_mutation_retries(0), ) .map_err(|error| RuntimeError::Registry(error.to_string()))?; let templates = diff --git a/crates/registry-breg-review/src/main.rs b/crates/registry-breg-review/src/main.rs index 42f4fa6911..42dfeae742 100644 --- a/crates/registry-breg-review/src/main.rs +++ b/crates/registry-breg-review/src/main.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::process::ExitCode; use registry_breg_review::check::{self, CheckRequest, OutputFormat}; -use registry_breg_review::{command, router, serve, RuntimeConfig}; +use registry_breg_review::{command, router, serve, LogLevel, RuntimeConfig}; use tracing::Level; use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; @@ -13,9 +13,16 @@ const LOG_VARIABLE: &str = "BREG_REVIEW_LOG"; /// The operational log level. The vocabulary is closed so a typo cannot /// silently turn logging off or on. -fn operational_log_level(value: Result) -> Result { +fn operational_log_level( + value: Result, + configured: LogLevel, +) -> Result { match value { - Err(std::env::VarError::NotPresent) => Ok(Level::INFO), + Err(std::env::VarError::NotPresent) => Ok(match configured { + LogLevel::Error => Level::ERROR, + LogLevel::Warn => Level::WARN, + LogLevel::Info => Level::INFO, + }), Ok(value) => match value.as_str() { "error" => Ok(Level::ERROR), "warn" => Ok(Level::WARN), @@ -54,13 +61,17 @@ async fn main() -> ExitCode { )); } - let level = match operational_log_level(std::env::var(LOG_VARIABLE)) { - Ok(level) => level, - Err(error) => { - eprintln!("breg-review: {error}"); - return ExitCode::from(2); - } + let environment_level = match std::env::var(LOG_VARIABLE) { + Err(std::env::VarError::NotPresent) => None, + value => match operational_log_level(value, LogLevel::Info) { + Ok(level) => Some(level), + Err(error) => { + eprintln!("breg-review: {error}"); + return ExitCode::from(2); + } + }, }; + // A refused runtime file is reported in the shared human shape on // standard error (CFG-DIAG-2), naming members and never their values. let config = match RuntimeConfig::load(path) { @@ -70,6 +81,10 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } }; + let level = environment_level.unwrap_or_else(|| { + operational_log_level(Err(std::env::VarError::NotPresent), config.log_level) + .expect("the runtime log level was validated") + }); tracing_subscriber::registry() .with( tracing_subscriber::fmt::layer() @@ -121,15 +136,25 @@ mod tests { #[test] fn the_log_level_vocabulary_is_closed() { + use registry_breg_review::LogLevel; + assert_eq!( - operational_log_level(Err(std::env::VarError::NotPresent)), + operational_log_level(Err(std::env::VarError::NotPresent), LogLevel::Info), Ok(Level::INFO) ); assert_eq!( - operational_log_level(Ok("warn".to_owned())), + operational_log_level(Err(std::env::VarError::NotPresent), LogLevel::Warn), + Ok(Level::WARN) + ); + assert_eq!( + operational_log_level(Ok("error".to_owned()), LogLevel::Warn), + Ok(Level::ERROR) + ); + assert_eq!( + operational_log_level(Ok("warn".to_owned()), LogLevel::Info), Ok(Level::WARN) ); - assert!(operational_log_level(Ok("debug".to_owned())).is_err()); - assert!(operational_log_level(Ok("trace".to_owned())).is_err()); + assert!(operational_log_level(Ok("debug".to_owned()), LogLevel::Info).is_err()); + assert!(operational_log_level(Ok("trace".to_owned()), LogLevel::Info).is_err()); } } diff --git a/crates/registry-breg/src/action_evidence_client.rs b/crates/registry-breg/src/action_evidence_client.rs index 2fab6480c0..930fae40bd 100644 --- a/crates/registry-breg/src/action_evidence_client.rs +++ b/crates/registry-breg/src/action_evidence_client.rs @@ -48,7 +48,7 @@ impl EvidenceProviderBinding { return Err(EvidenceAcquisitionFailure::Configuration); } let client = EvidenceClient::new( - config.with_max_response_bytes(MAXIMUM_ACTION_EVIDENCE_RESPONSE_BYTES), + config.with_maximum_response_bytes(MAXIMUM_ACTION_EVIDENCE_RESPONSE_BYTES), ) .map_err(|_| EvidenceAcquisitionFailure::Configuration)?; Ok(Self { diff --git a/crates/registry-breg/src/main.rs b/crates/registry-breg/src/main.rs index bb946adf25..b08654a680 100644 --- a/crates/registry-breg/src/main.rs +++ b/crates/registry-breg/src/main.rs @@ -5,8 +5,8 @@ use clap::Parser; use registry_breg::cli::Arguments; use registry_breg::runtime_config::load_runtime_config; use registry_breg::startup::{ - map_runtime_config_error, operational_log_level, prepare_loaded, serve, OperationalEvent, - OperationalLogLevel, + map_runtime_config_error, operational_log_level, prepare_loaded, select_operational_log_level, + serve, OperationalEvent, OperationalLogLevel, }; use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; @@ -14,24 +14,40 @@ use tracing_subscriber::prelude::*; #[tokio::main] async fn main() { let arguments = Arguments::parse(); - let level = match operational_log_level(std::env::var("BREG_LOG").ok().as_deref()) { - Ok(level) => level, - Err(error) => { + let environment_log_level = match std::env::var("BREG_LOG") { + Ok(value) => match operational_log_level(Some(&value)) { + Ok(_) => Some(value), + Err(error) => { + initialize_logging(OperationalLogLevel::Error); + OperationalEvent::StoppedWithError(error).emit(); + std::process::exit(2); + } + }, + Err(std::env::VarError::NotPresent) => None, + Err(std::env::VarError::NotUnicode(_)) => { initialize_logging(OperationalLogLevel::Error); - OperationalEvent::StoppedWithError(error).emit(); + OperationalEvent::StoppedWithError(registry_breg::startup::StartupError::Logging) + .emit(); std::process::exit(2); } }; - initialize_logging_filter(level); if !arguments.runtime_config.is_absolute() { + initialize_logging_filter( + select_operational_log_level(environment_log_level.as_deref(), "info") + .expect("the environment log level was validated"), + ); OperationalEvent::Stopped.emit(); std::process::exit(2); } - OperationalEvent::StartupBegan.emit(); let config = match load_runtime_config(&arguments.runtime_config) { Ok(config) => config, Err(error) => { + initialize_logging_filter( + select_operational_log_level(environment_log_level.as_deref(), "info") + .expect("the environment log level was validated"), + ); + OperationalEvent::StartupBegan.emit(); // The operator reads every reader diagnostic, with its position // and fix, on stderr; the operational log on stdout carries only // the closed refusal class. @@ -41,6 +57,13 @@ async fn main() { std::process::exit(1); } }; + let level = select_operational_log_level( + environment_log_level.as_deref(), + config.log_level().as_str(), + ) + .expect("the environment and runtime log levels were validated"); + initialize_logging_filter(level); + OperationalEvent::StartupBegan.emit(); let clients_unrestricted = config.authentication().oidc().admits_every_client(); let prepared = match prepare_loaded(config).await { Ok(prepared) => prepared, diff --git a/crates/registry-breg/src/review_store.rs b/crates/registry-breg/src/review_store.rs index 67d299518f..aac1512b80 100644 --- a/crates/registry-breg/src/review_store.rs +++ b/crates/registry-breg/src/review_store.rs @@ -281,8 +281,8 @@ impl ReviewAuthorityClient { } // Submission, cancellation, and result-poll claims all guard one // outbound exchange on this authority's client, so their lease is - // sized from that client's request timeout. - let lease_seconds = outbound_lease_seconds(client.request_timeout()); + // sized from that client's per-attempt timeout. + let lease_seconds = outbound_lease_seconds(client.attempt_timeout()); Ok(Self { authority, client, diff --git a/crates/registry-breg/src/runtime_config.rs b/crates/registry-breg/src/runtime_config.rs index 5807495799..774b99d6c0 100644 --- a/crates/registry-breg/src/runtime_config.rs +++ b/crates/registry-breg/src/runtime_config.rs @@ -123,6 +123,30 @@ const EVENT_DESTINATION_PATH_SCHEMA_PATTERN: &str = pub const RUNTIME_CONFIG_API_VERSION: &str = "id.registrystack.org/formats/breg/runtime/v1alpha1"; pub const RUNTIME_CONFIG_KIND: &str = "BRegRuntimeConfig"; +/// The operational log level selected by the runtime file when `BREG_LOG` +/// is not set. The closed vocabulary prevents configuration from enabling +/// debug output or dependency logging. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[cfg_attr(feature = "schema", derive(serde::Serialize, schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum LogLevel { + Error, + Warn, + #[default] + Info, +} + +impl LogLevel { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Error => "error", + Self::Warn => "warn", + Self::Info => "info", + } + } +} + /// The apiVersion a runtime file carried before it took the shared identifier /// form, refused with the value that replaces it. const RETIRED_RUNTIME_API_VERSIONS: &[RetiredApiVersion<'static>] = &[RetiredApiVersion { @@ -845,6 +869,7 @@ const fn runtime_config_loader() -> RuntimeConfigLoader { #[derive(Clone)] pub struct RuntimeConfig { + log_level: LogLevel, listener: ListenerConfig, identity: DeploymentIdentity, secret_providers: SecretProvidersConfig, @@ -914,6 +939,7 @@ impl RuntimeConfig { metrics.validate_separate_binding(&listener)?; } Ok(Self { + log_level: raw.log_level, listener, identity, secret_providers, @@ -936,6 +962,11 @@ impl RuntimeConfig { }) } + #[must_use] + pub const fn log_level(&self) -> LogLevel { + self.log_level + } + pub fn activate_task_status( &self, compiled: &CompiledRegistry, @@ -997,7 +1028,7 @@ impl RuntimeConfig { .with_audience(oauth.assertion_audience.clone()) .with_resource(oauth.resource.clone()) .with_scopes(oauth.scopes.clone()) - .with_request_timeout(self.operational_timeouts.http_request) + .with_attempt_timeout(self.operational_timeouts.http_request) .with_connect_timeout(self.operational_timeouts.http_request); if let Some(reference) = &oauth.ca_bundle_ref { let ca = resolver.resolve_reference(reference)?; @@ -1044,7 +1075,7 @@ impl RuntimeConfig { .transpose()?; let client = registry_review_client::ReviewClient::new( registry_review_client::ReviewClientConfig::new(config.endpoint.clone()) - .with_request_timeout(self.operational_timeouts.http_request) + .with_attempt_timeout(self.operational_timeouts.http_request) .with_connect_timeout(self.operational_timeouts.http_request), ) .map_err(|_| RuntimeConfigError::InvalidBinding)?; @@ -1358,6 +1389,7 @@ impl fmt::Debug for RuntimeConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("RuntimeConfig") + .field("log_level", &self.log_level) .field("listener", &self.listener) .field("identity", &self.identity) .field("secret_providers", &self.secret_providers) @@ -2805,6 +2837,10 @@ struct RawRuntimeConfig { api_version: String, #[cfg(feature = "schema")] kind: String, + /// Operational log level when `BREG_LOG` is not set. Defaults to `info`; + /// accepted values are `error`, `warn`, and `info`. + #[serde(default)] + log_level: LogLevel, listener: RawRegistryListener, identity: RawDeploymentIdentity, secret_providers: registry_platform_config::SecretProvidersConfig, diff --git a/crates/registry-breg/src/startup.rs b/crates/registry-breg/src/startup.rs index 2c04d86eaa..13d1ad6d74 100644 --- a/crates/registry-breg/src/startup.rs +++ b/crates/registry-breg/src/startup.rs @@ -2201,6 +2201,16 @@ pub fn operational_log_level(value: Option<&str>) -> Result { } } +/// Select the process environment override, then the authored runtime level. +/// Both inputs use the same closed vocabulary and an invalid value refuses +/// startup rather than falling back. +pub fn select_operational_log_level( + environment: Option<&str>, + configured: &str, +) -> Result { + operational_log_level(environment.or(Some(configured))) +} + /// Verify the complete local package first, then require the configured /// database id, the package digest as the active package, the schema /// fingerprint, ready maintenance state, ownership, RLS, and ACL catalog diff --git a/crates/registry-breg/tests/postgres_refactor.rs b/crates/registry-breg/tests/postgres_refactor.rs index 39275f61c8..7b4c9374a1 100644 --- a/crates/registry-breg/tests/postgres_refactor.rs +++ b/crates/registry-breg/tests/postgres_refactor.rs @@ -261,7 +261,7 @@ async fn facility_action_is_atomic_checks_references_and_recovers_exactly_once() .await; lost.client = registry_breg_client::BaseRegistryClient::new( registry_breg_client::BaseRegistryClientConfig::new(lost.base_url.parse().unwrap()) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .unwrap(); let lost_contract = lost diff --git a/crates/registry-breg/tests/runtime_config.rs b/crates/registry-breg/tests/runtime_config.rs index 84e47c7b29..5145b3d9b3 100644 --- a/crates/registry-breg/tests/runtime_config.rs +++ b/crates/registry-breg/tests/runtime_config.rs @@ -218,6 +218,48 @@ operationalTimeouts: ) } +#[test] +fn runtime_log_level_defaults_to_info_and_accepts_the_closed_vocabulary() { + let fixture = RuntimeFixture::new(); + let base = valid_runtime(&fixture.secret_root, &fixture.package_root); + + let defaulted = + parse_runtime_config_with_env(&base, env_lookup).expect("the default log level loads"); + assert_eq!(defaulted.log_level().as_str(), "info"); + + for accepted in ["error", "warn", "info"] { + let configured = base.replacen( + &format!("kind: {RUNTIME_CONFIG_KIND}\n"), + &format!("kind: {RUNTIME_CONFIG_KIND}\nlogLevel: {accepted}\n"), + 1, + ); + let loaded = parse_runtime_config_with_env(&configured, env_lookup) + .unwrap_or_else(|error| panic!("{accepted:?} loads: {error}")); + assert_eq!(loaded.log_level().as_str(), accepted); + } +} + +#[test] +fn runtime_log_level_refuses_values_outside_the_closed_vocabulary() { + let fixture = RuntimeFixture::new(); + let base = valid_runtime(&fixture.secret_root, &fixture.package_root); + + for refused in ["debug", "trace", "off", "Info", ""] { + let yaml_value = if refused.is_empty() { "''" } else { refused }; + let configured = base.replacen( + &format!("kind: {RUNTIME_CONFIG_KIND}\n"), + &format!("kind: {RUNTIME_CONFIG_KIND}\nlogLevel: {yaml_value}\n"), + 1, + ); + let error = parse_runtime_config_with_env(&configured, env_lookup) + .expect_err("an unknown log level is refused"); + assert_eq!( + reader_refusal(&error), + ("config.unknown-variant", "/logLevel") + ); + } +} + fn runtime_with_event_destinations(fixture: &RuntimeFixture, bindings: &str) -> String { valid_runtime(&fixture.secret_root, &fixture.package_root).replace( "eventDestinations: {}\n", diff --git a/crates/registry-breg/tests/startup_http.rs b/crates/registry-breg/tests/startup_http.rs index 3f1421a7c5..a8b4d3acd2 100644 --- a/crates/registry-breg/tests/startup_http.rs +++ b/crates/registry-breg/tests/startup_http.rs @@ -28,7 +28,7 @@ use registry_breg::postgres::{ }; use registry_breg::runtime_config::{parse_runtime_config_with_env, RuntimeConfigError}; use registry_breg::startup::{ - operational_log_level, with_request_timeout_and_metrics_for_test, + operational_log_level, select_operational_log_level, with_request_timeout_and_metrics_for_test, with_request_timeout_for_test, BackgroundTask, BackgroundTaskStop, OperationalEvent, OperationalLogLevel, StartupError, WebhookStateTransitionCode, }; @@ -366,6 +366,16 @@ fn operational_log_level_is_a_closed_vocabulary() { assert!(operational_log_level(Some("error")).is_ok()); assert!(operational_log_level(Some("debug")).is_err()); assert!(operational_log_level(Some("breg=trace")).is_err()); + assert_eq!( + select_operational_log_level(None, "warn").expect("runtime level"), + tracing_subscriber::filter::LevelFilter::WARN + ); + assert_eq!( + select_operational_log_level(Some("error"), "warn").expect("environment override"), + tracing_subscriber::filter::LevelFilter::ERROR + ); + assert!(select_operational_log_level(Some("debug"), "info").is_err()); + assert!(select_operational_log_level(None, "debug").is_err()); } #[derive(Clone, Default)] diff --git a/crates/registry-bregctl/src/data_lifecycle.rs b/crates/registry-bregctl/src/data_lifecycle.rs index c55d289db0..e602662245 100644 --- a/crates/registry-bregctl/src/data_lifecycle.rs +++ b/crates/registry-bregctl/src/data_lifecycle.rs @@ -365,7 +365,7 @@ fn ingestion_client( token: &str, ) -> Result { let config = BaseRegistryClientConfig::new(base.as_url().clone()) - .with_request_timeout(DEFAULT_REQUEST_TIMEOUT) + .with_attempt_timeout(DEFAULT_REQUEST_TIMEOUT) .with_connect_timeout(DEFAULT_CONNECT_TIMEOUT) .with_user_agent(DATA_HTTP_USER_AGENT); let client = BaseRegistryClient::new(config).map_err(|_| DataLifecycleError::BRegUrl)?; diff --git a/crates/registry-bregctl/src/dev/examples/review.rs b/crates/registry-bregctl/src/dev/examples/review.rs index 458b9f60ab..f657ae9d23 100644 --- a/crates/registry-bregctl/src/dev/examples/review.rs +++ b/crates/registry-bregctl/src/dev/examples/review.rs @@ -13,8 +13,8 @@ pub(super) struct ReviewArgs { #[arg(long)] approve: bool, /// Casework authoring CLI from the same installation. - #[arg(long, default_value = "caseworkctl")] - caseworkctl_bin: PathBuf, + #[arg(long)] + caseworkctl_bin: Option, /// Casework runtime executable; otherwise resolve casework on PATH. #[arg(long)] casework_bin: Option, @@ -153,6 +153,24 @@ fn observation_is_pending(report: &Value) -> bool { } pub(super) fn prepare(args: ReviewArgs) -> Result { + // Resolve every rstack-managed helper before creating the destination, so + // an absent component or a conflicting override has no local effects. + let caseworkctl_bin = crate::selected_binary::resolve_managed( + crate::selected_binary::CASEWORKCTL, + args.caseworkctl_bin.as_deref(), + )? + .or(args.caseworkctl_bin) + .unwrap_or_else(|| PathBuf::from("caseworkctl")); + let breg_bin = crate::selected_binary::resolve_managed( + crate::selected_binary::BREG, + args.breg_bin.as_deref(), + )? + .or(args.breg_bin); + let casework_bin = crate::selected_binary::resolve_managed( + crate::selected_binary::CASEWORK, + args.casework_bin.as_deref(), + )? + .or(args.casework_bin); let root = std::path::absolute(&args.directory)?; if root.exists() { bail!("review example requires a fresh destination; existing projects and sessions are preserved"); @@ -177,7 +195,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { &logs, )?; ctl( - &args.caseworkctl_bin, + &caseworkctl_bin, vec![ "init".into(), path(&casework), @@ -214,7 +232,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { "--database-port".into(), ports[2].to_string(), ]; - if let Some(binary) = &args.breg_bin { + if let Some(binary) = &breg_bin { start.extend(["--breg-bin".into(), path(binary)]); } let source = ctl(&self_bin, start, &logs)?; @@ -256,7 +274,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { .context("source state has no parent")? .join("build/package"); ctl( - &args.caseworkctl_bin, + &caseworkctl_bin, vec![ "provider".into(), "pair".into(), @@ -274,7 +292,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { &logs, )?; ctl( - &args.caseworkctl_bin, + &caseworkctl_bin, vec!["check".into(), path(&casework)], &logs, )?; @@ -289,10 +307,10 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { "--database-port".into(), ports[4].to_string(), ]; - if let Some(binary) = &args.casework_bin { + if let Some(binary) = &casework_bin { start.extend(["--casework-bin".into(), path(binary)]); } - ctl(&args.caseworkctl_bin, start, &logs)?; + ctl(&caseworkctl_bin, start, &logs)?; let registration = ctl( &self_bin, vec![ @@ -335,7 +353,7 @@ pub(super) fn prepare(args: ReviewArgs) -> Result { )?; if args.approve { let credentials = ctl( - &args.caseworkctl_bin, + &caseworkctl_bin, vec![ "dev".into(), "token".into(), diff --git a/crates/registry-bregctl/src/dev/mod.rs b/crates/registry-bregctl/src/dev/mod.rs index e63ae219b6..e5b5199b93 100644 --- a/crates/registry-bregctl/src/dev/mod.rs +++ b/crates/registry-bregctl/src/dev/mod.rs @@ -1069,6 +1069,12 @@ fn canonical_yaml(bytes: &[u8], name: &str, format: &FormatSpec<'_>) -> Result Result { + // Bind an rstack-managed invocation to its selected runtime before this + // command creates or changes any retained development state. + let managed_breg = crate::selected_binary::resolve_managed( + crate::selected_binary::BREG, + args.breg_bin.as_deref(), + )?; let project = project(&args.project)?; let requested_issuer = args .issuer_project @@ -1252,7 +1258,10 @@ fn start(args: StartArgs) -> Result { "the retained successor database was explicitly removed; a successor package cannot initialize empty records. Remove the project's .breg/dev directory, then run bregctl dev start to begin a fresh session" ); } - let breg = executable("breg", args.breg_bin.as_deref())?; + let breg = match managed_breg { + Some(path) => path, + None => executable("breg", args.breg_bin.as_deref())?, + }; let docker = executable("docker", args.docker_bin.as_deref())?; // Identify the prerequisites before the session stops a container or // launches the supervisor: a breg from another release has to be named diff --git a/crates/registry-bregctl/src/lib.rs b/crates/registry-bregctl/src/lib.rs index de91e7fe0a..2eab3aa43c 100644 --- a/crates/registry-bregctl/src/lib.rs +++ b/crates/registry-bregctl/src/lib.rs @@ -11,7 +11,7 @@ use std::ffi::{OsStr, OsString}; use std::fs; use std::io::{self, Read, Write}; use std::path::{Component, Path, PathBuf}; -use std::process::ExitCode; +use std::process::{Command as ProcessCommand, ExitCode}; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; @@ -65,6 +65,7 @@ mod report; mod review_recovery; mod reviewed_migrations; mod safe_path; +mod selected_binary; mod starters; mod statistics_lifecycle; mod test_lifecycle; @@ -182,6 +183,12 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { + /// Run the production Base Registry Engine service with the selected runtime. + Serve(ServeArgs), + /// Operate the optional citizen-facing MCP gateway. + Mcp(McpArgs), + /// Operate the optional citizen review page. + Review(ReviewArgs), /// Create an authoring project in a new directory: a domain-neutral example, /// one derived from an embedded reference model with `--from`, or a shipped /// starter with `--template`. @@ -256,6 +263,67 @@ enum Command { FieldEncryption(FieldEncryptionArgs), } +#[derive(Debug, Args)] +struct ServeArgs { + /// Runtime configuration file passed unchanged to the Base Registry Engine runtime. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// BReg runtime executable for an unmanaged installation. + #[arg(long, hide = true, env = "BREG_BIN", value_name = "BINARY")] + breg_bin: Option, +} + +#[derive(Debug, Args)] +struct McpArgs { + /// MCP gateway executable for an unmanaged installation. + #[arg(long, hide = true, env = "BREG_MCP_BIN", value_name = "BINARY")] + breg_mcp_bin: Option, + + #[command(subcommand)] + command: SupportingServiceCommand, +} + +#[derive(Debug, Args)] +struct ReviewArgs { + /// Review page executable for an unmanaged installation. + #[arg(long, hide = true, env = "BREG_REVIEW_BIN", value_name = "BINARY")] + breg_review_bin: Option, + + #[command(subcommand)] + command: SupportingServiceCommand, +} + +#[derive(Debug, Subcommand)] +enum SupportingServiceCommand { + /// Check the runtime file offline, reading no secret and opening no socket. + Check(SupportingServiceCheckArgs), + /// Serve until terminated. + Serve(SupportingServiceServeArgs), +} + +#[derive(Debug, Args)] +struct SupportingServiceCheckArgs { + /// Runtime configuration file to check. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Exit 1 when a warning is reported. + #[arg(long)] + deny_warnings: bool, + + /// Fill environment expressions while checking the runtime file. + #[arg(long)] + environment: bool, +} + +#[derive(Debug, Args)] +struct SupportingServiceServeArgs { + /// Runtime configuration file for the service. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, +} + #[derive(Debug, Args)] struct FieldEncryptionArgs { #[command(subcommand)] @@ -2335,6 +2403,7 @@ where { let arguments: Vec = arguments.into_iter().map(Into::into).collect(); let machine_mode = requested_json(&arguments); + let format_explicit = report_format_requested(&arguments); if machine_mode && help_requested(&arguments) { let catalog = registry_cli_reference::binary_catalog( command(), @@ -2379,6 +2448,37 @@ where let format = cli.format; let result = match cli.command { + Command::Serve(args) => { + return serve(args, format, format_explicit, stdout, stderr); + } + Command::Mcp(args) => { + return supporting_service( + SupportingServiceInvocation { + command: "mcp", + spec: selected_binary::BREG_MCP, + explicit_binary: args.breg_mcp_bin, + action: args.command, + }, + format, + format_explicit, + stdout, + stderr, + ); + } + Command::Review(args) => { + return supporting_service( + SupportingServiceInvocation { + command: "review", + spec: selected_binary::BREG_REVIEW, + explicit_binary: args.breg_review_bin, + action: args.command, + }, + format, + format_explicit, + stdout, + stderr, + ); + } Command::Examples(args) => { return match dev::examples::run(args) { Ok(report) => write_examples_success(&report, format, stdout, stderr), @@ -2970,6 +3070,166 @@ where } } +fn serve( + args: ServeArgs, + format: OutputFormat, + format_explicit: bool, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + if format_explicit { + return reject_service_format("serve", format, stdout, stderr); + } + + let binary = + match selected_binary::resolve_managed(selected_binary::BREG, args.breg_bin.as_deref()) { + Ok(Some(binary)) => binary, + Ok(None) => args.breg_bin.unwrap_or_else(|| PathBuf::from("breg")), + Err(error) => { + let _ = writeln!(stderr, "bregctl serve could not select breg: {error:#}"); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + command.arg("--runtime-config").arg(args.runtime_config); + replace_with_service(command, "breg", stderr) +} + +struct SupportingServiceInvocation { + command: &'static str, + spec: selected_binary::HelperSpec, + explicit_binary: Option, + action: SupportingServiceCommand, +} + +fn supporting_service( + invocation: SupportingServiceInvocation, + format: OutputFormat, + format_explicit: bool, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + if matches!(&invocation.action, SupportingServiceCommand::Serve(_)) && format_explicit { + return reject_service_format(invocation.command, format, stdout, stderr); + } + + let binary = match selected_binary::resolve_managed( + invocation.spec, + invocation.explicit_binary.as_deref(), + ) { + Ok(Some(binary)) => binary, + Ok(None) => invocation + .explicit_binary + .unwrap_or_else(|| PathBuf::from(invocation.spec.binary)), + Err(error) => { + let _ = writeln!( + stderr, + "bregctl {} could not select {}: {error:#}", + invocation.command, invocation.spec.binary + ); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + match invocation.action { + SupportingServiceCommand::Serve(args) => { + command + .arg("--runtime-config") + .arg(args.runtime_config) + .arg("serve"); + } + SupportingServiceCommand::Check(args) => { + command + .arg("--runtime-config") + .arg(args.runtime_config) + .arg("check") + .arg("--format") + .arg(match format { + OutputFormat::Human => "human", + OutputFormat::Json => "json", + }); + if args.deny_warnings { + command.arg("--deny-warnings"); + } + if args.environment { + command.arg("--environment"); + } + } + } + replace_with_service(command, invocation.spec.binary, stderr) +} + +fn reject_service_format( + command: &'static str, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + let report = source_failure( + "usage", + diagnostic( + "usage.invalid", + "format", + &format!( + "--format does not apply to the {command} service because service logs keep the runtime contract" + ), + ), + DiagnosticArtifact::CommandArguments, + SuggestedAction::CorrectCommandUsage, + ); + match format { + OutputFormat::Json => { + let result = write_ctl_report(&mut *stdout, &report) + .map_err(io::Error::other) + .and_then(|()| writeln!(stdout)); + if result.is_err() { + let _ = writeln!(stderr, "bregctl: output could not be written"); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + } + OutputFormat::Human => { + let _ = writeln!( + stderr, + "error: --format does not apply to the {command} service" + ); + } + } + ExitCode::from(USAGE_EXIT) +} + +#[cfg(unix)] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn Write, +) -> ExitCode { + use std::os::unix::process::CommandExt as _; + + let error = command.exec(); + let _ = writeln!(stderr, "bregctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) +} + +#[cfg(not(unix))] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn Write, +) -> ExitCode { + match command.status() { + Ok(status) => ExitCode::from( + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(OPERATIONAL_FAILURE_EXIT), + ), + Err(error) => { + let _ = writeln!(stderr, "bregctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + fn proposal_retention_list( args: &ProposalRetentionListArgs, ) -> Result { @@ -7113,6 +7373,19 @@ fn requested_json(arguments: &[OsString]) -> bool { }) } +fn report_format_requested(arguments: &[OsString]) -> bool { + arguments + .iter() + .skip(1) + .take_while(|argument| *argument != "--") + .any(|argument| { + argument == "--format" + || argument + .to_str() + .is_some_and(|value| value.starts_with("--format=")) + }) +} + /// Describe a command line clap refused by the error kind and the argument /// name only, followed by the usage clap renders from the declared arguments. /// Clap's own message is never repeated because it quotes the rejected token, @@ -14066,6 +14339,9 @@ mod tests { assert_eq!( names, [ + "serve", + "mcp", + "review", "init", "check", "project", @@ -14098,6 +14374,35 @@ mod tests { ); } + #[test] + fn supporting_service_runtime_arguments_have_public_catalog_help() { + let reference = registry_cli_reference::command_reference( + command(), + None, + Some(registry_cli_reference::SYMBOLIC_LINK_REFUSAL), + ); + for service_name in ["mcp", "review"] { + let service = reference + .subcommands + .iter() + .find(|command| command.name == service_name) + .expect("supporting service subtree"); + for action_name in ["check", "serve"] { + let action = service + .subcommands + .iter() + .find(|command| command.name == action_name) + .expect("supporting service action"); + let runtime = action + .options + .iter() + .find(|option| option.display == "--runtime-config ") + .expect("runtime configuration option"); + assert!(runtime.description.contains("Runtime configuration file")); + } + } + } + #[test] fn example_reports_show_native_results_and_next_command_in_both_formats() { let report = json!({"ok":true,"command":"examples run","project":"/local/project","scenario":"first-record","attempt":"attempt-id","captures":{"first-record":{"id":"returned-id","entity":"entry"}},"results":{"get":{"data":{"name":"Synthetic record"}}},"nextCommand":"bregctl examples run reviewed-change . --step submit"}); diff --git a/crates/registry-bregctl/src/selected_binary.rs b/crates/registry-bregctl/src/selected_binary.rs new file mode 100644 index 0000000000..735ffebf97 --- /dev/null +++ b/crates/registry-bregctl/src/selected_binary.rs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Copy)] +pub(crate) struct HelperSpec { + pub(crate) binary: &'static str, + pub(crate) component: &'static str, + pub(crate) explicit_source: &'static str, + pub(crate) managed_env: &'static str, +} + +pub(crate) const BREG: HelperSpec = HelperSpec { + binary: "breg", + component: "breg", + explicit_source: "--breg-bin or BREG_BIN", + managed_env: "RSTACK_BREG_BIN", +}; + +pub(crate) const BREG_MCP: HelperSpec = HelperSpec { + binary: "breg-mcp", + component: "breg", + explicit_source: "--breg-mcp-bin or BREG_MCP_BIN", + managed_env: "RSTACK_BREG_MCP_BIN", +}; + +pub(crate) const BREG_REVIEW: HelperSpec = HelperSpec { + binary: "breg-review", + component: "breg", + explicit_source: "--breg-review-bin or BREG_REVIEW_BIN", + managed_env: "RSTACK_BREG_REVIEW_BIN", +}; + +pub(crate) const CASEWORKCTL: HelperSpec = HelperSpec { + binary: "caseworkctl", + component: "casework", + explicit_source: "--caseworkctl-bin", + managed_env: "RSTACK_CASEWORKCTL_BIN", +}; + +pub(crate) const CASEWORK: HelperSpec = HelperSpec { + binary: "casework", + component: "casework", + explicit_source: "--casework-bin", + managed_env: "RSTACK_CASEWORK_BIN", +}; + +pub(crate) fn resolve_managed( + spec: HelperSpec, + explicit: Option<&Path>, +) -> Result> { + resolve_managed_with_environment( + spec, + explicit, + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(spec.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + ) +} + +fn resolve_managed_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result> { + let Some(generation_dir) = generation_dir else { + return Ok(None); + }; + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(Path::new(generation_dir)).with_context(|| { + format!( + "rstack {release} selected generation {} cannot be resolved", + Path::new(generation_dir).display() + ) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let Some(managed_path) = managed_path else { + return missing_component(spec, &release); + }; + let selected = + canonical_helper(Path::new(managed_path)).or_else(|_| missing_component(spec, &release))?; + let layout = canonical_helper(&layout_path).or_else(|_| missing_component(spec, &release))?; + if selected != layout { + bail!( + "rstack {release} provided {} outside the selected generation helper path at {}; run `rstack install {}` to repair the selected generation", + spec.managed_env, + layout_path.display(), + spec.component + ); + } + if let Some(override_path) = explicit { + let override_identity = canonical_helper(override_path).with_context(|| { + format!( + "cannot resolve {} from {}", + spec.binary, spec.explicit_source + ) + })?; + if override_identity != selected { + bail!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected generation identity", + spec.explicit_source, + override_path.display(), + spec.binary, + selected.display() + ); + } + } + Ok(Some(selected)) +} + +fn canonical_helper(path: &Path) -> Result { + if !path.is_file() { + bail!("helper is not an installed file at {}", path.display()); + } + Ok(fs::canonicalize(path)?) +} + +fn missing_component(spec: HelperSpec, release: &str) -> Result { + bail!( + "rstack {release} selected generation has no usable {} helper; run `rstack install {}` and retry, bregctl will not install components automatically", + spec.binary, + spec.component + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn fixture(root: &Path, spec: HelperSpec) -> (PathBuf, PathBuf) { + let generation = root.join("generation"); + let selected = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"same version\n").unwrap(); + (generation, selected) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_helper() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), BREG); + let resolved = resolve_managed_with_environment( + BREG, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_refuses_a_same_version_runtime_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), CASEWORK); + let override_path = root.path().join("other-casework"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + CASEWORK, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("--casework-bin"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + assert!(diagnostic.contains("v-test"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_refuses_a_runtime_environment_outside_the_generation() { + let root = tempfile::tempdir().unwrap(); + let (generation, _selected) = fixture(root.path(), BREG); + let outside = root.path().join("outside-breg"); + fs::write(&outside, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + BREG, + None, + Some(generation.as_os_str()), + Some(outside.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("RSTACK_BREG_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_names_the_install_command_when_component_is_missing() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let error = resolve_managed_with_environment( + CASEWORKCTL, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!( + diagnostic.contains("rstack install casework"), + "{diagnostic}" + ); + assert!(diagnostic.contains("will not install"), "{diagnostic}"); + } + + #[test] + fn unmanaged_resolution_preserves_existing_lookup() { + assert_eq!( + resolve_managed_with_environment(BREG, None, None, None, None).unwrap(), + None + ); + } +} diff --git a/crates/registry-bregctl/tests/serve.rs b/crates/registry-bregctl/tests/serve.rs new file mode 100644 index 0000000000..8d24cbd924 --- /dev/null +++ b/crates/registry-bregctl/tests/serve.rs @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(arguments) + .env("BREG_BIN", binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("bregctl starts") +} + +#[test] +fn unmanaged_serve_preserves_runtime_arguments_streams_process_and_exit() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "breg", 17); + let capture = temp.path().join("capture"); + let child = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(["serve", "--runtime-config", "/tmp/runtime.yaml"]) + .env("BREG_BIN", &binary) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("bregctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!(output.status.code(), Some(17)); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\n") + ); +} + +#[test] +fn unmanaged_serve_falls_back_to_path_without_a_binary_override() { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), "breg", 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(["serve", "--runtime-config", "/tmp/path.yaml"]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("BREG_BIN") + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(21)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml"] + ); +} + +#[test] +fn managed_serve_uses_the_selected_generation_instead_of_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/breg/breg"); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, "breg", 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + let poisoned = runtime(&poisoned_directory, "breg", 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory.clone()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(["serve", "--runtime-config", "/tmp/managed.yaml"]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_BREG_BIN", &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove("BREG_BIN") + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(19)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml"] + ); + assert_ne!(selected, poisoned); +} + +#[test] +fn serve_rejects_json_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "breg", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "--format", + "json", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the runtime must not start"); +} + +#[test] +fn serve_rejects_explicit_human_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "breg", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "human", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--format does not apply")); + assert!(!capture.exists(), "the runtime must not start"); +} diff --git a/crates/registry-bregctl/tests/supporting_services.rs b/crates/registry-bregctl/tests/supporting_services.rs new file mode 100644 index 0000000000..1394b4a587 --- /dev/null +++ b/crates/registry-bregctl/tests/supporting_services.rs @@ -0,0 +1,355 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +#[derive(Clone, Copy)] +struct Service { + command: &'static str, + binary: &'static str, + unmanaged_env: &'static str, + managed_env: &'static str, +} + +const MCP: Service = Service { + command: "mcp", + binary: "breg-mcp", + unmanaged_env: "BREG_MCP_BIN", + managed_env: "RSTACK_BREG_MCP_BIN", +}; + +const REVIEW: Service = Service { + command: "review", + binary: "breg-review", + unmanaged_env: "BREG_REVIEW_BIN", + managed_env: "RSTACK_BREG_REVIEW_BIN", +}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nif [[ -n \"${{RUNTIME_LEASE_FD:-}}\" ]]; then printf 'lease-inherited\\n' >&\"$RUNTIME_LEASE_FD\"; fi\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(service: Service, binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args(arguments) + .env(service.unmanaged_env, binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("bregctl starts") +} + +#[test] +fn unmanaged_service_serve_preserves_arguments_streams_process_status_and_lease() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), service.binary, 17); + let capture = temp.path().join("capture"); + let lease = temp.path().join("lease"); + let mut launcher = Command::new("/bin/bash"); + launcher + .args([ + "-c", + "exec 9>\"$RUNTIME_LEASE_CAPTURE\"; export RUNTIME_LEASE_FD=9; export DYLD_FALLBACK_LIBRARY_PATH=\"${RESTORE_DYLD_FALLBACK_LIBRARY_PATH:-}\"; export DYLD_LIBRARY_PATH=\"${RESTORE_DYLD_LIBRARY_PATH:-}\"; export LD_LIBRARY_PATH=\"${RESTORE_LD_LIBRARY_PATH:-}\"; exec \"$CTL_BIN\" \"$@\"", + "bregctl-launcher", + service.command, + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ]) + .env("CTL_BIN", env!("CARGO_BIN_EXE_bregctl")) + .env(service.unmanaged_env, &binary) + .env("RUNTIME_CAPTURE", &capture) + .env("RUNTIME_LEASE_CAPTURE", &lease) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (source, backup) in [ + ( + "DYLD_FALLBACK_LIBRARY_PATH", + "RESTORE_DYLD_FALLBACK_LIBRARY_PATH", + ), + ("DYLD_LIBRARY_PATH", "RESTORE_DYLD_LIBRARY_PATH"), + ("LD_LIBRARY_PATH", "RESTORE_LD_LIBRARY_PATH"), + ] { + if let Some(value) = std::env::var_os(source) { + launcher.env(backup, value); + } + } + let child = launcher.spawn().expect("bregctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!(output.status.code(), Some(17), "{}", service.binary); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!(fs::read_to_string(lease).unwrap(), "lease-inherited\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\nserve\n") + ); + } +} + +#[test] +fn unmanaged_service_serve_falls_back_to_path() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), service.binary, 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args([ + service.command, + "serve", + "--runtime-config", + "/tmp/path.yaml", + ]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove(service.unmanaged_env) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(21), "{}", service.binary); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml", "serve"] + ); + } +} + +#[test] +fn managed_service_serve_uses_the_selected_generation_instead_of_path() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/breg").join(service.binary); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, service.binary, 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + runtime(&poisoned_directory, service.binary, 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args([ + service.command, + "serve", + "--runtime-config", + "/tmp/managed.yaml", + ]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env(service.managed_env, &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove(service.unmanaged_env) + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(19), "{}", service.binary); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml", "serve"] + ); + } +} + +#[test] +fn managed_service_does_not_fall_back_when_the_optional_binary_is_absent() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + runtime(&poisoned_directory, service.binary, 97); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(poisoned_directory).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args([ + service.command, + "serve", + "--runtime-config", + "/tmp/managed.yaml", + ]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove(service.managed_env) + .env_remove(service.unmanaged_env) + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(3), "{}", service.binary); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains(service.binary), "{stderr}"); + assert!(stderr.contains("rstack install breg"), "{stderr}"); + assert!(!capture.exists(), "a PATH runtime must not start"); + } +} + +#[test] +fn managed_service_refuses_a_same_version_unmanaged_override() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/breg").join(service.binary); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, service.binary, 19); + let outside = runtime(temp.path(), "same-version-outside", 97); + fs::write(&outside, fs::read(&selected).unwrap()).unwrap(); + let capture = temp.path().join("capture"); + let output = Command::new(env!("CARGO_BIN_EXE_bregctl")) + .args([ + service.command, + "serve", + "--runtime-config", + "/tmp/managed.yaml", + ]) + .env(service.unmanaged_env, &outside) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env(service.managed_env, &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .output() + .expect("bregctl starts"); + + assert_eq!(output.status.code(), Some(3), "{}", service.binary); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("selected generation"), "{stderr}"); + assert!(stderr.contains("matching version string"), "{stderr}"); + assert!(!capture.exists(), "the unmanaged override must not start"); + } +} + +#[test] +fn service_check_forwards_report_format_and_check_options() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), service.binary, 23); + let capture = temp.path().join("capture"); + let output = run( + service, + &binary, + &capture, + &[ + "--format", + "json", + service.command, + "check", + "--runtime-config", + "/tmp/check.yaml", + "--deny-warnings", + "--environment", + ], + ); + + assert_eq!(output.status.code(), Some(23), "{}", service.binary); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + [ + "--runtime-config", + "/tmp/check.yaml", + "check", + "--format", + "json", + "--deny-warnings", + "--environment" + ] + ); + } +} + +#[test] +fn service_serve_rejects_explicit_json_report_format_before_launch() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), service.binary, 0); + let capture = temp.path().join("capture"); + let output = run( + service, + &binary, + &capture, + &[ + service.command, + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "json", + ], + ); + + assert_eq!(output.status.code(), Some(2), "{}", service.binary); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the service must not start"); + } +} + +#[test] +fn service_serve_rejects_explicit_human_report_format_before_launch() { + for service in [MCP, REVIEW] { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), service.binary, 0); + let capture = temp.path().join("capture"); + let output = run( + service, + &binary, + &capture, + &[ + "--format", + "human", + service.command, + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ], + ); + + assert_eq!(output.status.code(), Some(2), "{}", service.binary); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--format does not apply")); + assert!(!capture.exists(), "the service must not start"); + } +} diff --git a/crates/registry-casework-breg/src/config.rs b/crates/registry-casework-breg/src/config.rs index 65f54df3f6..64b77d3962 100644 --- a/crates/registry-casework-breg/src/config.rs +++ b/crates/registry-casework-breg/src/config.rs @@ -154,7 +154,7 @@ pub fn build_provider( let endpoint = url::Url::parse(&binding.token_endpoint).map_err(|_| ReviewContextError::Invalid)?; let mut token = PrivateKeyJwtConfig::new(endpoint, client_id, key) - .with_request_timeout(Duration::from_secs(30)) + .with_attempt_timeout(Duration::from_secs(30)) .with_connect_timeout(Duration::from_secs(10)); if let Some(audience) = &binding.client_assertion_audience { token = token.with_audience(audience); @@ -170,8 +170,8 @@ pub fn build_provider( url::Url::parse(&binding.base_url).map_err(|_| ReviewContextError::Invalid)?, ) .with_token_provider(Arc::new(token)) - .with_max_response_bytes(1024 * 1024) - .with_request_timeout(Duration::from_secs(30)) + .with_maximum_response_bytes(1024 * 1024) + .with_attempt_timeout(Duration::from_secs(30)) .with_connect_timeout(Duration::from_secs(10)); let client = BaseRegistryClient::new(config).map_err(|_| ReviewContextError::Invalid)?; crate::BregContextProvider::new( diff --git a/crates/registry-casework-client-node/README.md b/crates/registry-casework-client-node/README.md index 50b4c88996..2610417184 100644 --- a/crates/registry-casework-client-node/README.md +++ b/crates/registry-casework-client-node/README.md @@ -7,6 +7,8 @@ Producer, human-review, and Directory calls take a bearer token and an explicit Casework profile. Current-source context operations also take the caller's source profile; submitted-context reviews need no provider or source profile. The binding does not retain credentials and exposes no browser credential API. +Transport configuration uses `attemptTimeoutMilliseconds`, +`connectTimeoutMilliseconds`, and `maximumResponseBytes`. `reviewTasks(token, profile, { ownership: 'assigned-to-me' })` filters the reviewer inbox before pagination. `supervisoryReviewTasks` provides current @@ -56,7 +58,7 @@ for expiry, revocation, and changes that require another approval. A keyed mutation whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same -key up to `maxMutationRetries` times: 0 to 2, default 2, and 0 sends it once. +key up to `maximumMutationRetries` times: 0 to 2, default 2, and 0 sends it once. Reads, unkeyed operations, and review request create and cancel are never resent. When the returned `CaseworkClientError` still reports `outcomeUnknown`, the mutation may have taken effect: recover by sending it again under the same diff --git a/crates/registry-casework-client-node/__test__/facade.test-d.ts b/crates/registry-casework-client-node/__test__/facade.test-d.ts index 88c8bcdc9a..e211d0bc8c 100644 --- a/crates/registry-casework-client-node/__test__/facade.test-d.ts +++ b/crates/registry-casework-client-node/__test__/facade.test-d.ts @@ -2,7 +2,17 @@ import { CaseworkClient, CaseworkClientError, submissionDigest, type ContentDige type PolicyBinding, type ReviewCreateRequest, type ReviewRequestAccepted, type ReviewTask, type SupervisoryReviewTask, type StaffingDiagnostic, type ReviewTaskDecisionRequest, type ReviewStaffingQuery, type ReviewCaseloadMoveRequest, type ReviewClockRecomputeRequest } from '../client' -const client = new CaseworkClient({ baseUrl: 'https://casework.example.test/', maxMutationRetries: 0 }) +const client = new CaseworkClient({ baseUrl: 'https://casework.example.test/', attemptTimeoutMilliseconds: 1500, maximumResponseBytes: 1500, maximumMutationRetries: 0 }) +// @ts-expect-error the public response-bound name is maximumResponseBytes +new CaseworkClient({ baseUrl: 'https://casework.example.test/', maxResponseBytes: 1500 }) +// @ts-expect-error null is not an omission in JavaScript configuration +new CaseworkClient({ baseUrl: 'https://casework.example.test/', attemptTimeoutMilliseconds: null }) +// @ts-expect-error trust roots are PEM text, not binary data +new CaseworkClient({ baseUrl: 'https://casework.example.test/', trustedRootCertificates: Buffer.from('certificate') }) +// @ts-expect-error the per-exchange timeout is named for one attempt +new CaseworkClient({ baseUrl: 'https://casework.example.test/', requestTimeoutMilliseconds: 1500 }) +// @ts-expect-error the retry ceiling uses the authored maximum spelling +new CaseworkClient({ baseUrl: 'https://casework.example.test/', maxMutationRetries: 0 }) const token = 'one-call-secret', profile = 'staff', id = '00000000-0000-4000-8000-000000000001' const digest: ContentDigest = `sha256:${'a'.repeat(64)}` const subject: SubjectBinding = { source: 'registry', type: 'proposal', id: 'proposal-1', version: '1', digest } diff --git a/crates/registry-casework-client-node/__test__/facade.test.js b/crates/registry-casework-client-node/__test__/facade.test.js index 522156c741..3dfdbf192a 100644 --- a/crates/registry-casework-client-node/__test__/facade.test.js +++ b/crates/registry-casework-client-node/__test__/facade.test.js @@ -74,11 +74,20 @@ test('a configuration value the sanitizer refuses is a configuration error', () const { CaseworkClient, CaseworkClientError } = require('../client'); const baseUrl = 'https://casework.example/'; for (const [label, config] of [ - ['an unsafe integer', { baseUrl, requestTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], - ['a non-finite number', { baseUrl, maxResponseBytes: Infinity }], + ['an unsafe integer', { baseUrl, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['an overflowing response bound', { baseUrl, maximumResponseBytes: 4294967297 }], + ['a negative attempt timeout', { baseUrl, attemptTimeoutMilliseconds: -1 }], + ['a fractional connect timeout', { baseUrl, connectTimeoutMilliseconds: 1.5 }], + ['a non-finite number', { baseUrl, maximumResponseBytes: Infinity }], + ['an unknown member', { baseUrl, maximumResponseByte: 1500 }], + ['the retired response-bound spelling', { baseUrl, maxResponseBytes: 1500 }], + ['the retired timeout spelling', { baseUrl, requestTimeoutMilliseconds: 1500 }], + ['the retired retry spelling', { baseUrl, maxMutationRetries: 0 }], ['an undefined member', { baseUrl, userAgent: undefined }], + ['a null member', { baseUrl, userAgent: null }], + ['a binary trust bundle', { baseUrl, trustedRootCertificates: Buffer.from('not PEM text') }], ['a function', { baseUrl, userAgent: () => 'casework-test' }], - ['a date', { baseUrl, requestTimeoutMilliseconds: new Date(0) }], + ['a date', { baseUrl, attemptTimeoutMilliseconds: new Date(0) }], ['a proxy', new Proxy({ baseUrl }, {})], ['no configuration', undefined], ]) { diff --git a/crates/registry-casework-client-node/__test__/mutation-retry.test.js b/crates/registry-casework-client-node/__test__/mutation-retry.test.js index a4de938ec5..fd07c1f017 100644 --- a/crates/registry-casework-client-node/__test__/mutation-retry.test.js +++ b/crates/registry-casework-client-node/__test__/mutation-retry.test.js @@ -82,7 +82,7 @@ test('an absence answered 503 is resent once under the same key and succeeds', a }); test('a retry ceiling of zero sends an absence once and reports the outcome unknown', async (context) => { - const { client, requests } = await retrying(context, [UNAVAILABLE, CREATED], { maxMutationRetries: 0 }); + const { client, requests } = await retrying(context, [UNAVAILABLE, CREATED], { maximumMutationRetries: 0 }); const { CaseworkClientError } = require('../client'); await assert.rejects(client.createAbsence('one-call-secret', 'administrator', 2, 'absence-1', ABSENCE), (error) => { @@ -97,7 +97,7 @@ test('a retry ceiling of zero sends an absence once and reports the outcome unkn }); test('a 4xx refusal of an absence is never resent and reports the outcome known', async (context) => { - const { client, requests } = await retrying(context, [PRECONDITION_FAILED, CREATED], { maxMutationRetries: 2 }); + const { client, requests } = await retrying(context, [PRECONDITION_FAILED, CREATED], { maximumMutationRetries: 2 }); await assert.rejects(client.createAbsence('one-call-secret', 'administrator', 2, 'absence-1', ABSENCE), (error) => { assert.equal(error.kind, 'problem'); @@ -122,13 +122,13 @@ test('a request refused before any exchange reports the outcome known', async (c test('a retry ceiling outside zero to two is a configuration error', () => { const { CaseworkClient, CaseworkClientError } = require('../client'); - for (const maxMutationRetries of [0, 1, 2]) { - assert.ok(new CaseworkClient({ baseUrl: 'https://casework.example/', maxMutationRetries })); + for (const maximumMutationRetries of [0, 1, 2]) { + assert.ok(new CaseworkClient({ baseUrl: 'https://casework.example/', maximumMutationRetries })); } - for (const maxMutationRetries of [3, 255, 256, -1, 1.5, '1']) { - assert.throws(() => new CaseworkClient({ baseUrl: 'https://casework.example/', maxMutationRetries }), (error) => { - assert.ok(error instanceof CaseworkClientError, `${maxMutationRetries}`); - assert.equal(error.kind, 'configuration', `${maxMutationRetries}`); + for (const maximumMutationRetries of [3, 255, 256, -1, 1.5, '1']) { + assert.throws(() => new CaseworkClient({ baseUrl: 'https://casework.example/', maximumMutationRetries }), (error) => { + assert.ok(error instanceof CaseworkClientError, `${maximumMutationRetries}`); + assert.equal(error.kind, 'configuration', `${maximumMutationRetries}`); assert.equal(error.outcomeUnknown, false); return true; }); diff --git a/crates/registry-casework-client-node/client.d.ts b/crates/registry-casework-client-node/client.d.ts index e5607c9ee1..4f2babc96c 100644 --- a/crates/registry-casework-client-node/client.d.ts +++ b/crates/registry-casework-client-node/client.d.ts @@ -8,11 +8,11 @@ export type JsonObject = { readonly [key: string]: JsonValue } export interface CaseworkClientConfig { baseUrl: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + trustedRootCertificates?: string /** * How many times an idempotency-keyed mutation whose outcome is unknown is * resent, identically and under the same key, before its error is @@ -20,7 +20,7 @@ export interface CaseworkClientConfig { * unkeyed operations, and review request create and cancel are never * resent. Any other value is a `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export type PageStatus = 'complete' | 'budget-exhausted' | 'source-unavailable' diff --git a/crates/registry-casework-client-node/client.js b/crates/registry-casework-client-node/client.js index b71d650f5b..c9bca29727 100644 --- a/crates/registry-casework-client-node/client.js +++ b/crates/registry-casework-client-node/client.js @@ -85,12 +85,50 @@ function sanitize(value, kind) { return cloneJson(value, { nodes: 0, bytes: 0, active: new WeakSet() }, 0, kind); } +function configurationValue(config) { + if (!config || typeof config !== 'object' || Array.isArray(config) || isProxy(config)) { + throw normalized({}, 'configuration'); + } + const prototype = Object.getPrototypeOf(config); + if (prototype !== Object.prototype && prototype !== null) { + throw normalized({}, 'configuration'); + } + const allowed = new Set([ + 'baseUrl', + 'attemptTimeoutMilliseconds', + 'connectTimeoutMilliseconds', + 'maximumResponseBytes', + 'userAgent', + 'trustedRootCertificates', + 'maximumMutationRetries', + ]); + const copy = Object.create(null); + for (const key of Reflect.ownKeys(config)) { + if (typeof key !== 'string' || !allowed.has(key)) throw normalized({}, 'configuration'); + const descriptor = Object.getOwnPropertyDescriptor(config, key); + if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw normalized({}, 'configuration'); + } + const value = descriptor.value; + if (key === 'baseUrl' || key === 'userAgent' || key === 'trustedRootCertificates') { + if (typeof value !== 'string') throw normalized({}, 'configuration'); + copy[key] = value; + } else { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { + throw normalized({}, 'configuration'); + } + copy[key] = value; + } + } + return copy; +} + const NativeCaseworkClient = native.CaseworkClient; class CaseworkClient { constructor(config) { try { - this.native = new NativeCaseworkClient(sanitize(config, 'configuration')); + this.native = new NativeCaseworkClient(configurationValue(config)); } catch (error) { throw normalized(error, 'configuration'); } diff --git a/crates/registry-casework-client-node/index.d.ts b/crates/registry-casework-client-node/index.d.ts index 995a725c46..8187f8f213 100644 --- a/crates/registry-casework-client-node/index.d.ts +++ b/crates/registry-casework-client-node/index.d.ts @@ -63,16 +63,16 @@ export declare class CaseworkClient { export interface CaseworkClientConfig { baseUrl: string - requestTimeoutMilliseconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number - maxResponseBytes?: number + maximumResponseBytes?: number userAgent?: string trustedRootCertificates?: string /** * How many times a keyed mutation whose outcome is unknown is resent under * the same idempotency key: 0 to 2, default 2, and 0 disables the resend. */ - maxMutationRetries?: number + maximumMutationRetries?: number } export interface CaseworkOutcome { diff --git a/crates/registry-casework-client-node/src/lib.rs b/crates/registry-casework-client-node/src/lib.rs index d8450550ac..cab48924a8 100644 --- a/crates/registry-casework-client-node/src/lib.rs +++ b/crates/registry-casework-client-node/src/lib.rs @@ -41,14 +41,14 @@ pub fn submission_digest( #[napi(object)] pub struct CaseworkClientConfig { pub base_url: String, - pub request_timeout_milliseconds: Option, - pub connect_timeout_milliseconds: Option, - pub max_response_bytes: Option, + pub attempt_timeout_milliseconds: Option, + pub connect_timeout_milliseconds: Option, + pub maximum_response_bytes: Option, pub user_agent: Option, pub trusted_root_certificates: Option, /// How many times a keyed mutation whose outcome is unknown is resent under /// the same idempotency key: 0 to 2, default 2, and 0 disables the resend. - pub max_mutation_retries: Option, + pub maximum_mutation_retries: Option, } /// Identify a human task grant read without positional string ambiguity. @@ -78,14 +78,16 @@ impl CaseworkClient { binding_error("configuration", "Casework client configuration is invalid") })?; let mut core = CoreConfig::new(base_url); - if let Some(value) = config.request_timeout_milliseconds { - core = core.with_request_timeout(Duration::from_millis(u64::from(value))); + if let Some(value) = config.attempt_timeout_milliseconds { + let value = positive_u32(value)?; + core = core.with_attempt_timeout(Duration::from_millis(u64::from(value))); } if let Some(value) = config.connect_timeout_milliseconds { + let value = positive_u32(value)?; core = core.with_connect_timeout(Duration::from_millis(u64::from(value))); } - if let Some(value) = config.max_response_bytes { - core = core.with_max_response_bytes(u64::from(value)); + if let Some(value) = config.maximum_response_bytes { + core = core.with_maximum_response_bytes(u64::from(positive_u32(value)?)); } if let Some(value) = config.user_agent { core = core.with_user_agent(value); @@ -93,8 +95,8 @@ impl CaseworkClient { if let Some(value) = config.trusted_root_certificates { core = core.with_trusted_root_certificates(value.into_bytes()); } - if let Some(value) = config.max_mutation_retries { - core = core.with_max_mutation_retries(mutation_retries(value)?); + if let Some(value) = config.maximum_mutation_retries { + core = core.with_maximum_mutation_retries(mutation_retries(value)?); } CoreClient::new(core) .map(|inner| Self { inner }) @@ -1256,6 +1258,19 @@ fn binding_error(kind: &'static str, message: &'static str) -> NapiError { ) } +/// Validate a JavaScript number before narrowing it to the native millisecond +/// and byte-count representation. +fn positive_u32(value: f64) -> Result { + if value.is_finite() && value.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(&value) { + Ok(value as u32) + } else { + Err(binding_error( + "configuration", + "Casework client configuration is invalid", + )) + } +} + /// The retry ceiling as a JavaScript number. Only a whole number in the /// `u8` range reaches the client, which refuses one above its own bound. fn mutation_retries(value: f64) -> Result { diff --git a/crates/registry-casework-client-py/README.md b/crates/registry-casework-client-py/README.md index fd18010792..92de339e4b 100644 --- a/crates/registry-casework-client-py/README.md +++ b/crates/registry-casework-client-py/README.md @@ -24,6 +24,8 @@ profile. Supply those explicitly on each call. Mutations also require the revision and idempotency key chosen by the caller; the binding never replaces them. Results use the canonical Rust client's camel-case wire DTOs inside a `{"kind": "complete", "value": ..., "trace_id": ...}` envelope. +Transport configuration uses `attempt_timeout_seconds`, +`connect_timeout_seconds`, and `maximum_response_bytes`. Requester review operations omit a source profile. Officer task reads, claims, assignment, delegation, drafts, decisions, history, notes, and review clocks @@ -62,7 +64,7 @@ or idempotency expiry explicitly; the binding never replaces a key. A keyed mutation whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same -key up to `max_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. +key up to `maximum_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. Reads, unkeyed operations, and review request create and cancel are never resent. When the returned `CaseworkClientError` still reports `outcome_unknown`, the mutation may have taken effect: recover by sending it diff --git a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi index 3047d5f638..d191101aad 100644 --- a/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi +++ b/crates/registry-casework-client-py/python/registry_casework_client/__init__.pyi @@ -807,7 +807,7 @@ class ReviewClockRecomputeResult(TypedDict): def submission_digest(producer: str, source_namespace: str, request: ReviewCreateRequest) -> ContentDigest: ... class CaseworkClient: - def __init__(self, base_url: str, request_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, max_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, max_mutation_retries: int | None = None) -> None: ... + def __init__(self, base_url: str, attempt_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, maximum_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, maximum_mutation_retries: int | None = None) -> None: ... def description(self, token: str, profile: str) -> Complete[Description]: ... def create_or_recover_review_request(self, token: str, profile: str, idempotency_key: str, request: ReviewCreateRequest, expected_submission_digest: ContentDigest) -> Complete[ReviewRequestAccepted]: ... def review_request(self, token: str, profile: str, request_id: Uuid) -> Complete[ReviewRequestView]: ... diff --git a/crates/registry-casework-client-py/src/lib.rs b/crates/registry-casework-client-py/src/lib.rs index c6275bd2f5..d3f19d7f46 100644 --- a/crates/registry-casework-client-py/src/lib.rs +++ b/crates/registry-casework-client-py/src/lib.rs @@ -22,7 +22,7 @@ use casework_client_sdk::{ use pyo3::{ exceptions::{PyException, PyRuntimeError}, prelude::*, - types::{PyBool, PyDict}, + types::{PyBool, PyDict, PyFloat, PyInt}, }; use serde::{de::DeserializeOwned, Serialize}; use url::Url; @@ -238,12 +238,55 @@ fn mutation_retries(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { value.extract::().map_err(|_| invalid()) } -fn duration(py: Python<'_>, value: f64) -> PyResult { - Duration::try_from_secs_f64(value).map_err(|_| { +fn duration(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + if value.is_exact_instance_of::() { + let value = value.extract::().map_err(|_| { + binding_error( + py, + "configuration", + "Casework client configuration is invalid", + ) + })?; + Duration::try_from_secs_f64(value).map_err(|_| { + binding_error( + py, + "configuration", + "client timeouts must be finite non-negative seconds", + ) + }) + } else if value.is_exact_instance_of::() { + value + .extract::() + .map(Duration::from_secs) + .map_err(|_| { + binding_error( + py, + "configuration", + "Casework client configuration is invalid", + ) + }) + } else { + Err(binding_error( + py, + "configuration", + "Casework client configuration is invalid", + )) + } +} + +fn parse_maximum_response_bytes(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + if !value.is_exact_instance_of::() { + return Err(binding_error( + py, + "configuration", + "Casework client configuration is invalid", + )); + } + value.extract::().map_err(|_| { binding_error( py, "configuration", - "client timeouts must be finite non-negative seconds", + "Casework client configuration is invalid", ) }) } @@ -385,17 +428,17 @@ struct CaseworkClient { #[pymethods] impl CaseworkClient { #[new] - #[pyo3(signature = (base_url, request_timeout_seconds=None, connect_timeout_seconds=None, max_response_bytes=None, user_agent=None, trusted_root_certificates=None, max_mutation_retries=None))] + #[pyo3(signature = (base_url, attempt_timeout_seconds=None, connect_timeout_seconds=None, maximum_response_bytes=None, user_agent=None, trusted_root_certificates=None, maximum_mutation_retries=None))] #[allow(clippy::too_many_arguments)] fn new( py: Python<'_>, base_url: &str, - request_timeout_seconds: Option, - connect_timeout_seconds: Option, - max_response_bytes: Option, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, + connect_timeout_seconds: Option<&Bound<'_, PyAny>>, + maximum_response_bytes: Option<&Bound<'_, PyAny>>, user_agent: Option, trusted_root_certificates: Option>, - max_mutation_retries: Option<&Bound<'_, PyAny>>, + maximum_mutation_retries: Option<&Bound<'_, PyAny>>, ) -> PyResult { let base_url = Url::parse(base_url).map_err(|_| { binding_error( @@ -405,14 +448,14 @@ impl CaseworkClient { ) })?; let mut config = CaseworkClientConfig::new(base_url); - if let Some(value) = request_timeout_seconds { - config = config.with_request_timeout(duration(py, value)?); + if let Some(value) = attempt_timeout_seconds { + config = config.with_attempt_timeout(duration(py, value)?); } if let Some(value) = connect_timeout_seconds { config = config.with_connect_timeout(duration(py, value)?); } - if let Some(value) = max_response_bytes { - config = config.with_max_response_bytes(value); + if let Some(value) = maximum_response_bytes { + config = config.with_maximum_response_bytes(parse_maximum_response_bytes(py, value)?); } if let Some(value) = user_agent { config = config.with_user_agent(value); @@ -420,8 +463,8 @@ impl CaseworkClient { if let Some(value) = trusted_root_certificates { config = config.with_trusted_root_certificates(value); } - if let Some(value) = max_mutation_retries { - config = config.with_max_mutation_retries(mutation_retries(py, value)?); + if let Some(value) = maximum_mutation_retries { + config = config.with_maximum_mutation_retries(mutation_retries(py, value)?); } let inner = py .detach(|| RustClient::new(config)) diff --git a/crates/registry-casework-client-py/tests/python/test_construction.py b/crates/registry-casework-client-py/tests/python/test_construction.py index 190a76d7ac..96208e9494 100644 --- a/crates/registry-casework-client-py/tests/python/test_construction.py +++ b/crates/registry-casework-client-py/tests/python/test_construction.py @@ -15,11 +15,45 @@ def test_constructs_without_retaining_credentials(self) -> None: self.assertIsInstance(client, CaseworkClient) self.assertNotIn("token", repr(client).lower()) + def test_none_means_omission_for_optional_configuration(self) -> None: + client = CaseworkClient( + "https://casework.example.invalid/tenant", + attempt_timeout_seconds=None, + connect_timeout_seconds=None, + maximum_response_bytes=None, + user_agent=None, + trusted_root_certificates=None, + maximum_mutation_retries=None, + ) + self.assertIsInstance(client, CaseworkClient) + def test_invalid_configuration_has_a_stable_kind(self) -> None: with self.assertRaises(CaseworkClientError) as raised: CaseworkClient("not a URL") self.assertEqual(raised.exception.kind, "configuration") + def test_numeric_configuration_is_lossless_and_uses_the_public_name(self) -> None: + invalid_configurations = ( + {"attempt_timeout_seconds": True}, + {"attempt_timeout_seconds": 2**64}, + {"connect_timeout_seconds": False}, + {"maximum_response_bytes": True}, + {"maximum_response_bytes": 2**64}, + ) + for options in invalid_configurations: + with self.subTest(options=options), self.assertRaises(CaseworkClientError) as raised: + CaseworkClient("https://casework.example.invalid/", **options) + self.assertEqual(raised.exception.kind, "configuration") + + with self.assertRaises(TypeError): + CaseworkClient( + "https://casework.example.invalid/", request_timeout_seconds=1.0 + ) + with self.assertRaises(TypeError): + CaseworkClient( + "https://casework.example.invalid/", max_mutation_retries=0 + ) + def test_token_error_does_not_repeat_credential(self) -> None: secret = "bad token with spaces canary" client = CaseworkClient("https://casework.example.invalid/") diff --git a/crates/registry-casework-client-py/tests/python/test_mutation_retry.py b/crates/registry-casework-client-py/tests/python/test_mutation_retry.py index ae704877df..c90ff29eb1 100644 --- a/crates/registry-casework-client-py/tests/python/test_mutation_retry.py +++ b/crates/registry-casework-client-py/tests/python/test_mutation_retry.py @@ -121,7 +121,7 @@ def test_a_503_is_resent_once_under_the_same_key_and_succeeds(self) -> None: def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> None: self.script(UNAVAILABLE, CREATED) - client = CaseworkClient(self.base_url, max_mutation_retries=0) + client = CaseworkClient(self.base_url, maximum_mutation_retries=0) with self.assertRaises(CaseworkClientError) as raised: client.create_absence("one-call-token", "administrator", 2, KEY, ABSENCE) @@ -134,7 +134,7 @@ def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> N def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> None: self.script(PRECONDITION_FAILED, CREATED) - client = CaseworkClient(self.base_url, max_mutation_retries=2) + client = CaseworkClient(self.base_url, maximum_mutation_retries=2) with self.assertRaises(CaseworkClientError) as raised: client.create_absence("one-call-token", "administrator", 2, KEY, ABSENCE) @@ -147,11 +147,11 @@ def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> No def test_a_ceiling_outside_zero_to_two_is_a_configuration_error(self) -> None: for retries in (0, 1, 2): with self.subTest(retries=retries): - CaseworkClient(self.base_url, max_mutation_retries=retries) + CaseworkClient(self.base_url, maximum_mutation_retries=retries) for retries in (3, 255, 256, -1, 1.5, "1", True): with self.subTest(retries=retries): with self.assertRaises(CaseworkClientError) as raised: - CaseworkClient(self.base_url, max_mutation_retries=retries) + CaseworkClient(self.base_url, maximum_mutation_retries=retries) self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) diff --git a/crates/registry-casework-client-py/tests/python/test_typing_contract.py b/crates/registry-casework-client-py/tests/python/test_typing_contract.py index 2acbc8da70..27bcbfce57 100644 --- a/crates/registry-casework-client-py/tests/python/test_typing_contract.py +++ b/crates/registry-casework-client-py/tests/python/test_typing_contract.py @@ -12,6 +12,32 @@ class TypingContractTests(unittest.TestCase): + def test_client_configuration_uses_the_canonical_authored_names(self) -> None: + module = ast.parse(STUB.read_text(encoding="utf-8")) + client = next( + node + for node in module.body + if isinstance(node, ast.ClassDef) and node.name == "CaseworkClient" + ) + constructor = next( + node + for node in client.body + if isinstance(node, ast.FunctionDef) and node.name == "__init__" + ) + self.assertEqual( + [argument.arg for argument in constructor.args.args], + [ + "self", + "base_url", + "attempt_timeout_seconds", + "connect_timeout_seconds", + "maximum_response_bytes", + "user_agent", + "trusted_root_certificates", + "maximum_mutation_retries", + ], + ) + def test_admission_selects_a_policy_and_acceptance_keeps_the_exact_binding(self) -> None: module = ast.parse(STUB.read_text(encoding="utf-8")) classes = {node.name: node for node in module.body if isinstance(node, ast.ClassDef)} diff --git a/crates/registry-casework-client/README.md b/crates/registry-casework-client/README.md index 0098b7b482..53fc6de0f1 100644 --- a/crates/registry-casework-client/README.md +++ b/crates/registry-casework-client/README.md @@ -83,12 +83,12 @@ key, headers, and body bytes, at most twice by default, waiting 250 ms and then 500 ms, or the service's `Retry-After` when that is longer and at most 5 seconds. Casework answers every 503 with `Retry-After: 5`, so a resend after one waits 5 seconds. A longer requested wait ends the retries. Configure the -count with `CaseworkClientConfig::with_max_mutation_retries` (0 to 2; 0 +count with `CaseworkClientConfig::with_maximum_mutation_retries` (0 to 2; 0 disables the resend). Reads, unkeyed operations (task grant revocations, task assertions and previews), and the review request create and cancel calls, which go through the review client's single exchange, are -never resent. Each attempt has the full request timeout, so a call can take up -to three request timeouts plus the waits. +never resent. Each attempt has the full attempt timeout, so a call can take up +to three attempt timeouts plus the waits. When the returned error still reports `is_outcome_unknown()`, recover by sending the same request with the same key, which the service replays or diff --git a/crates/registry-casework-client/src/client.rs b/crates/registry-casework-client/src/client.rs index a9d58c636a..abba06a848 100644 --- a/crates/registry-casework-client/src/client.rs +++ b/crates/registry-casework-client/src/client.rs @@ -59,8 +59,8 @@ const ORIGINAL_ATTEMPT_HEADER: &str = "registry-casework-attempt"; pub struct CaseworkClient { http: reqwest::Client, base_url: ServiceBaseUrl, - max_response_bytes: u64, - max_mutation_retries: u8, + maximum_response_bytes: u64, + maximum_mutation_retries: u8, review: ProducerReviewClient, } @@ -69,8 +69,8 @@ impl fmt::Debug for CaseworkClient { formatter .debug_struct("CaseworkClient") .field("base_url", &"") - .field("max_response_bytes", &self.max_response_bytes) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_response_bytes", &self.maximum_response_bytes) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -78,9 +78,13 @@ impl fmt::Debug for CaseworkClient { impl CaseworkClient { pub fn new(config: CaseworkClientConfig) -> Result { let mut review_config = ProducerReviewClientConfig::new(config.base_url.clone()) - .with_request_timeout(config.request_timeout) + .with_attempt_timeout(config.attempt_timeout) .with_connect_timeout(config.connect_timeout) - .with_max_response_bytes(config.max_response_bytes.min(MAXIMUM_REVIEW_RESPONSE_BYTES)); + .with_maximum_response_bytes( + config + .maximum_response_bytes + .min(MAXIMUM_REVIEW_RESPONSE_BYTES), + ); if let Some(user_agent) = &config.user_agent { review_config = review_config.with_user_agent(user_agent.clone()); } @@ -91,7 +95,7 @@ impl CaseworkClient { ProducerReviewClient::new(review_config).map_err(CaseworkClientError::from_review)?; let base_url = config.validate()?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config.trusted_root_certificates.as_deref(), @@ -100,8 +104,8 @@ impl CaseworkClient { Ok(Self { http, base_url, - max_response_bytes: config.max_response_bytes, - max_mutation_retries: config.max_mutation_retries, + maximum_response_bytes: config.maximum_response_bytes, + maximum_mutation_retries: config.maximum_mutation_retries, review, }) } @@ -636,7 +640,7 @@ impl CaseworkClient { Some(trace_id), )); } - let body = read_bounded(response, self.max_response_bytes) + let body = read_bounded(response, self.maximum_response_bytes) .await .map_err(|error| CaseworkClientError::Transport { kind: read_failure_kind(&error), @@ -1510,7 +1514,7 @@ impl CaseworkClient { expected_status: StatusCode, ) -> Result, CaseworkClientError> { let request = &request; - retry_keyed_mutation(self.max_mutation_retries, || async move { + retry_keyed_mutation(self.maximum_mutation_retries, || async move { let Some(attempt) = request.try_clone() else { return KeyedMutationAttempt::Settled(Err(CaseworkClientError::invalid_request( "the request could not be prepared for sending", @@ -1539,7 +1543,7 @@ impl CaseworkClient { expected_status: StatusCode, ) -> Result, CaseworkClientError> { let request = &request; - retry_keyed_mutation(self.max_mutation_retries, || async move { + retry_keyed_mutation(self.maximum_mutation_retries, || async move { let Some(attempt) = request.try_clone() else { return KeyedMutationAttempt::Settled(Err(CaseworkClientError::invalid_request( "the request could not be prepared for sending", @@ -1586,7 +1590,7 @@ impl CaseworkClient { Some(trace_id), )); } - let body = read_bounded(response, self.max_response_bytes) + let body = read_bounded(response, self.maximum_response_bytes) .await .map_err(|error| CaseworkClientError::Transport { kind: read_failure_kind(&error), diff --git a/crates/registry-casework-client/src/config.rs b/crates/registry-casework-client/src/config.rs index 54434f9fc9..251e0ee12b 100644 --- a/crates/registry-casework-client/src/config.rs +++ b/crates/registry-casework-client/src/config.rs @@ -14,12 +14,12 @@ const MAXIMUM_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; #[derive(Clone)] pub struct CaseworkClientConfig { pub(crate) base_url: Url, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>, - pub(crate) max_mutation_retries: u8, + pub(crate) maximum_mutation_retries: u8, } impl CaseworkClientConfig { @@ -27,18 +27,20 @@ impl CaseworkClientConfig { pub fn new(base_url: Url) -> Self { Self { base_url, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_REQUEST_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, - max_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, user_agent: None, trusted_root_certificates: None, - max_mutation_retries: DEFAULT_MUTATION_RETRIES, + maximum_mutation_retries: DEFAULT_MUTATION_RETRIES, } } + /// Set the timeout for one HTTP exchange attempt. An authorized same-key + /// mutation retry receives a fresh attempt timeout. #[must_use] - pub fn with_request_timeout(mut self, value: Duration) -> Self { - self.request_timeout = value; + pub fn with_attempt_timeout(mut self, value: Duration) -> Self { + self.attempt_timeout = value; self } @@ -48,9 +50,10 @@ impl CaseworkClientConfig { self } + /// Set the response-body ceiling for every exchange. #[must_use] - pub fn with_max_response_bytes(mut self, value: u64) -> Self { - self.max_response_bytes = value; + pub fn with_maximum_response_bytes(mut self, value: u64) -> Self { + self.maximum_response_bytes = value; self } @@ -71,8 +74,8 @@ impl CaseworkClientConfig { /// returned. Defaults to 2, accepts at most 2, and 0 disables the /// resend. #[must_use] - pub fn with_max_mutation_retries(mut self, value: u8) -> Self { - self.max_mutation_retries = value; + pub fn with_maximum_mutation_retries(mut self, value: u8) -> Self { + self.maximum_mutation_retries = value; self } @@ -80,12 +83,13 @@ impl CaseworkClientConfig { let base_url = ServiceBaseUrl::new(self.base_url.clone()).map_err(|_| { CaseworkClientError::configuration("the service base URL is not usable") })?; - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(CaseworkClientError::configuration( "client timeouts must be greater than zero", )); } - if self.max_response_bytes == 0 || self.max_response_bytes > MAXIMUM_RESPONSE_BYTES { + if self.maximum_response_bytes == 0 || self.maximum_response_bytes > MAXIMUM_RESPONSE_BYTES + { return Err(CaseworkClientError::configuration( "the response body bound is outside the accepted range", )); @@ -99,7 +103,7 @@ impl CaseworkClientConfig { "the trusted root certificate bundle exceeds the accepted bound", )); } - if self.max_mutation_retries > MAXIMUM_MUTATION_RETRIES { + if self.maximum_mutation_retries > MAXIMUM_MUTATION_RETRIES { return Err(CaseworkClientError::configuration( "the mutation retry count exceeds the accepted bound", )); @@ -113,15 +117,15 @@ impl fmt::Debug for CaseworkClientConfig { formatter .debug_struct("CaseworkClientConfig") .field("base_url", &"") - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("user_agent_present", &self.user_agent.is_some()) .field( "trusted_root_certificates_present", &self.trusted_root_certificates.is_some(), ) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -148,20 +152,37 @@ mod tests { fn the_mutation_retry_count_is_bounded_before_any_client_exists() { let url = Url::parse("https://casework.example.invalid/").expect("fixture URL"); let excessive_retries = CaseworkClientConfig::new(url.clone()) - .with_max_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1); + .with_maximum_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1); assert!(matches!( excessive_retries.validate(), Err(CaseworkClientError::Configuration { .. }) )); for retries in 0..=MAXIMUM_MUTATION_RETRIES { assert!(CaseworkClientConfig::new(url.clone()) - .with_max_mutation_retries(retries) + .with_maximum_mutation_retries(retries) .validate() .is_ok()); } assert_eq!( - CaseworkClientConfig::new(url).max_mutation_retries, + CaseworkClientConfig::new(url).maximum_mutation_retries, DEFAULT_MUTATION_RETRIES ); } + + #[test] + fn the_maximum_response_bytes_are_bounded_before_any_client_exists() { + let url = Url::parse("https://casework.example.invalid/").expect("fixture URL"); + for maximum in [0, MAXIMUM_RESPONSE_BYTES + 1] { + assert!(matches!( + CaseworkClientConfig::new(url.clone()) + .with_maximum_response_bytes(maximum) + .validate(), + Err(CaseworkClientError::Configuration { .. }) + )); + } + assert!(CaseworkClientConfig::new(url) + .with_maximum_response_bytes(MAXIMUM_RESPONSE_BYTES) + .validate() + .is_ok()); + } } diff --git a/crates/registry-casework-client/src/lib.rs b/crates/registry-casework-client/src/lib.rs index c7de245350..92df35c3d5 100644 --- a/crates/registry-casework-client/src/lib.rs +++ b/crates/registry-casework-client/src/lib.rs @@ -5,7 +5,7 @@ //! generates one. A keyed mutation whose outcome is unknown (a timeout or //! broken exchange after the request was sent, or a 5xx answer) is resent, //! byte for byte and under the same key, at most -//! `CaseworkClientConfig::with_max_mutation_retries` times (2 by default, 0 +//! `CaseworkClientConfig::with_maximum_mutation_retries` times (2 by default, 0 //! disables it). Reads, unkeyed operations, and the review request create and //! cancel calls are never resent. A 5xx answer may follow a commit: when //! `CaseworkClientError::is_outcome_unknown` is still true, the safe recovery diff --git a/crates/registry-casework-client/tests/mutation_retry.rs b/crates/registry-casework-client/tests/mutation_retry.rs index acf2247c3a..e793e634de 100644 --- a/crates/registry-casework-client/tests/mutation_retry.rs +++ b/crates/registry-casework-client/tests/mutation_retry.rs @@ -270,7 +270,7 @@ async fn a_timed_out_absence_is_resent_identically_under_the_same_key() { ]) .await; let token = token(); - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let complete = client .create_absence( @@ -561,7 +561,7 @@ async fn the_retry_count_is_honored() { let token = token(); let mut config = config(&address); if let Some(retries) = retries { - config = config.with_max_mutation_retries(retries); + config = config.with_maximum_mutation_retries(retries); } let error = client(config) .create_absence( @@ -590,8 +590,8 @@ async fn zero_retries_disables_the_resend() { let token = token(); let error = client( config(&address) - .with_request_timeout(Duration::from_millis(500)) - .with_max_mutation_retries(0), + .with_attempt_timeout(Duration::from_millis(500)) + .with_maximum_mutation_retries(0), ) .create_absence( CaseworkAuth::new(&token, "staff"), @@ -735,7 +735,7 @@ async fn an_unreadable_4xx_answer_is_unknown_but_never_resent() { (Unfinished::Close, TransportKind::Exchange), ] { let (address, requests, server) = serve_unfinished_conflict(ending).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let token = token(); let error = client .create_absence( diff --git a/crates/registry-casework/tests/review_http.rs b/crates/registry-casework/tests/review_http.rs index 9492c91598..f38c89b87b 100644 --- a/crates/registry-casework/tests/review_http.rs +++ b/crates/registry-casework/tests/review_http.rs @@ -512,7 +512,7 @@ async fn native_review_client(app: axum::Router) -> (CaseworkClient, tokio::task .parse() .expect("test service URL"), ) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .expect("candidate native client"); (client, server) diff --git a/crates/registry-caseworkctl/src/dev/mod.rs b/crates/registry-caseworkctl/src/dev/mod.rs index 7c66e3166c..d1277b4f2d 100644 --- a/crates/registry-caseworkctl/src/dev/mod.rs +++ b/crates/registry-caseworkctl/src/dev/mod.rs @@ -806,6 +806,12 @@ fn issuer_keys(issuer: &str) -> Result> { } fn start(args: StartArgs) -> Result { + // Bind an rstack-managed invocation to its selected runtime before this + // command creates or changes any retained development state. + let managed_casework = crate::selected_binary::resolve_managed( + crate::selected_binary::CASEWORK, + args.casework_bin.as_deref(), + )?; let project = project(&args.project)?; let requested_issuer = args .issuer_project @@ -991,7 +997,10 @@ fn start(args: StartArgs) -> Result { if clients.integrations.is_some() { integrations::validate_bindings(&root, &project)?; } - let casework = executable("casework", args.casework_bin.as_deref())?; + let casework = match managed_casework { + Some(path) => path, + None => executable("casework", args.casework_bin.as_deref())?, + }; let docker = executable("docker", args.docker_bin.as_deref())?; // Identify the prerequisites before the session stops a container or // launches the supervisor: a casework from another release has to diff --git a/crates/registry-caseworkctl/src/lib.rs b/crates/registry-caseworkctl/src/lib.rs index 18e711f6c1..9466ba7c55 100644 --- a/crates/registry-caseworkctl/src/lib.rs +++ b/crates/registry-caseworkctl/src/lib.rs @@ -9,6 +9,7 @@ mod provider_pair; mod report; #[cfg(feature = "schema")] pub mod schema; +mod selected_binary; mod templates; use anyhow::Result; use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum}; @@ -17,7 +18,7 @@ use std::{ ffi::OsString, io::{self, Write}, path::PathBuf, - process::ExitCode, + process::{Command as ProcessCommand, ExitCode}, }; #[derive(Debug, Parser)] #[command(name="caseworkctl",version=registry_platform_buildinfo::DISPLAY_VERSION,about="Compile and operate bounded review policies")] @@ -36,6 +37,8 @@ enum OutputFormat { } #[derive(Debug, Subcommand)] enum Command { + /// Run the production Casework service with the selected runtime. + Serve(ServeArgs), /// Create a standalone or source-backed ReviewPolicy project. Init { /// New project directory; existing destinations are never overwritten. @@ -108,6 +111,17 @@ enum Command { #[command(name = "__dev-service-guard", hide = true)] DevServiceGuard(dev::ServiceGuardArgs), } + +#[derive(Debug, Args)] +struct ServeArgs { + /// Runtime configuration file passed unchanged to the Casework runtime. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Casework runtime executable for an unmanaged installation. + #[arg(long, hide = true, env = "CASEWORK_BIN", value_name = "BINARY")] + casework_bin: Option, +} #[derive(Debug, Subcommand)] enum ProviderCommand { /// Create an approval project from a Base Registry Engine (BReg) Action and print missing configuration. @@ -130,9 +144,9 @@ enum ProviderCommand { /// Package directory written with a new project; omit to inspect the package without writing it. #[arg(long, value_name = "DIRECTORY", requires = "breg_package")] package_output: Option, - /// Matching bregctl executable used for public project and package inspection. - #[arg(long, default_value = "bregctl", value_name = "BINARY")] - bregctl_bin: PathBuf, + /// Matching bregctl executable used for public project and package inspection; otherwise use the rstack-selected helper or bregctl on PATH. + #[arg(long, value_name = "BINARY")] + bregctl_bin: Option, }, /// Verify native context and reader authority, then optionally retain its provider export. Pair { @@ -148,9 +162,9 @@ enum ProviderCommand { /// Base Registry Engine (BReg) action that declares the required review context. #[arg(long)] action_id: String, - /// Matching bregctl executable used to verify the producer package. - #[arg(long, default_value = "bregctl", value_name = "BINARY")] - bregctl_bin: PathBuf, + /// Matching bregctl executable used to verify the producer package; otherwise use the rstack-selected helper or bregctl on PATH. + #[arg(long, value_name = "BINARY")] + bregctl_bin: Option, /// Write the verified provider export and description reference after validation. #[arg(long)] apply: bool, @@ -193,9 +207,9 @@ struct CheckArgs { /// Limit native package comparison to this declared provider. #[arg(long, requires = "against_breg_package")] provider_id: Option, - /// Matching bregctl executable used for native package verification. - #[arg(long, default_value = "bregctl", value_name = "BINARY")] - bregctl_bin: PathBuf, + /// Matching bregctl executable used for native package verification; otherwise use the rstack-selected helper or bregctl on PATH. + #[arg(long, value_name = "BINARY")] + bregctl_bin: Option, /// Runtime configuration file #[arg(long, value_name = "FILE")] runtime_config: Option, @@ -246,15 +260,21 @@ pub fn main_entry_from( return ExitCode::from(report::USAGE_EXIT); } }; - let kind = command_kind(&cli.command); - let command = cli.command.report_command(); - if command == "internal" { - return match run(cli.command) { + let Cli { format, command } = cli; + let command = match command { + Command::Serve(args) => return serve(args, format, stdout, stderr), + command => command, + }; + let kind = command_kind(&command); + let report_command = command.report_command(); + if report_command == "internal" { + return match run(command) { Ok(_) => ExitCode::SUCCESS, Err(_) => ExitCode::from(1), }; } - let result = run(cli.command).and_then(|value| report::CliReport::from_value(command, value)); + let result = + run(command).and_then(|value| report::CliReport::from_value(report_command, value)); let (value, exit) = match result { Ok(value) => { let value = serde_json::to_value(value).unwrap_or_default(); @@ -289,18 +309,18 @@ pub fn main_entry_from( } else { report::ReportStatus::DomainRefusal }; - let value = report::CliReport::failure(command, status, diagnostics) + let value = report::CliReport::failure(report_command, status, diagnostics) .and_then(|value| serde_json::to_value(value).map_err(Into::into)) .unwrap_or_default(); (value, exit) } }; - let output = if matches!(cli.format, OutputFormat::Human) && exit != 0 { + let output = if matches!(format, OutputFormat::Human) && exit != 0 { stderr as &mut dyn Write } else { stdout as &mut dyn Write }; - let _ = match cli.format { + let _ = match format { OutputFormat::Json => report::write(&value, output), OutputFormat::Human => render_human(&value, output), }; @@ -430,6 +450,7 @@ impl Command { fn report_command(&self) -> &'static str { match self { Self::Init { .. } => "init", + Self::Serve(_) => "serve", Self::Check(_) => "check", Self::Explain(_) => "explain", Self::Test(_) => "test", @@ -458,6 +479,7 @@ impl Command { fn run(command: Command) -> Result { match command { + Command::Serve(_) => unreachable!("serve replaces the ctl process before report dispatch"), Command::Init { project, template } => project::init(&project, &template), Command::Check(args) => { let mut result = project::check(&args.project, args.production, args.deny_warnings); @@ -471,11 +493,12 @@ fn run(command: Command) -> Result { } let mut report = result?; if let Some(package) = args.against_breg_package { + let bregctl = bregctl_binary(args.bregctl_bin.as_deref())?; report["bregPackage"] = provider_pair::compare( &args.project, &package, args.provider_id.as_deref(), - &args.bregctl_bin, + &bregctl, )?; } Ok(report) @@ -494,15 +517,18 @@ fn run(command: Command) -> Result { package_output, bregctl_bin, }, - } => provider_add::add( - &project, - &breg_project, - &action, - &pattern, - breg_package.as_deref(), - package_output.as_deref(), - &bregctl_bin, - ), + } => { + let bregctl = bregctl_binary(bregctl_bin.as_deref())?; + provider_add::add( + &project, + &breg_project, + &action, + &pattern, + breg_package.as_deref(), + package_output.as_deref(), + &bregctl, + ) + } Command::Provider { command: ProviderCommand::Pair { @@ -513,14 +539,17 @@ fn run(command: Command) -> Result { bregctl_bin, apply, }, - } => provider_pair::pair( - &project, - &provider_id, - &breg_package, - &action_id, - &bregctl_bin, - apply, - ), + } => { + let bregctl = bregctl_binary(bregctl_bin.as_deref())?; + provider_pair::pair( + &project, + &provider_id, + &breg_package, + &action_id, + &bregctl, + apply, + ) + } Command::Lifecycle => Ok( json!({"ok":true,"command":"lifecycle","lifecycles":[registry_casework_core::review_lifecycle()]}), ), @@ -556,6 +585,96 @@ fn run(command: Command) -> Result { Command::DevServiceGuard(args) => Ok(json!({"ok":dev::run_service_guard(args)?})), } } + +fn serve( + args: ServeArgs, + format: OutputFormat, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> ExitCode { + if matches!(format, OutputFormat::Json) { + let diagnostic = registry_platform_yaml::Diagnostic::error( + "casework.usage.invalid", + "format", + "--format does not apply to the serve command because service logs keep the Casework runtime contract.", + "Remove --format and retry the serve command.", + ); + let result = + report::CliReport::failure("usage", report::ReportStatus::UsageError, vec![diagnostic]) + .and_then(|report| serde_json::to_value(report).map_err(Into::into)) + .and_then(|value| report::write(&value, stdout).map_err(Into::into)); + if result.is_err() { + let _ = writeln!(stderr, "caseworkctl: output could not be written"); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + return ExitCode::from(report::USAGE_EXIT); + } + + let binary = match selected_binary::resolve_managed( + selected_binary::CASEWORK, + args.casework_bin.as_deref(), + ) { + Ok(Some(binary)) => binary, + Ok(None) => args + .casework_bin + .unwrap_or_else(|| PathBuf::from("casework")), + Err(error) => { + let _ = writeln!( + stderr, + "caseworkctl serve could not select casework: {error:#}" + ); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + command + .arg("--runtime-config") + .arg(args.runtime_config) + .arg("serve"); + replace_with_service(command, "casework", stderr) +} + +#[cfg(unix)] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn Write, +) -> ExitCode { + use std::os::unix::process::CommandExt as _; + + let error = command.exec(); + let _ = writeln!(stderr, "caseworkctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) +} + +#[cfg(not(unix))] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn Write, +) -> ExitCode { + match command.status() { + Ok(status) => ExitCode::from( + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(OPERATIONAL_FAILURE_EXIT), + ), + Err(error) => { + let _ = writeln!(stderr, "caseworkctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + +fn bregctl_binary(explicit: Option<&std::path::Path>) -> Result { + Ok( + selected_binary::resolve_managed(selected_binary::BREGCTL, explicit)? + .or_else(|| explicit.map(std::path::Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from("bregctl")), + ) +} + fn render_human(value: &Value, out: &mut (impl Write + ?Sized)) -> io::Result<()> { let diagnostics: Vec = serde_json::from_value(value["diagnostics"].clone()).map_err(io::Error::other)?; diff --git a/crates/registry-caseworkctl/src/selected_binary.rs b/crates/registry-caseworkctl/src/selected_binary.rs new file mode 100644 index 0000000000..7b30cc92ce --- /dev/null +++ b/crates/registry-caseworkctl/src/selected_binary.rs @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Copy)] +pub(crate) struct HelperSpec { + pub(crate) binary: &'static str, + pub(crate) component: &'static str, + pub(crate) explicit_source: &'static str, + pub(crate) managed_env: &'static str, +} + +pub(crate) const BREGCTL: HelperSpec = HelperSpec { + binary: "bregctl", + component: "breg", + explicit_source: "--bregctl-bin", + managed_env: "RSTACK_BREGCTL_BIN", +}; + +pub(crate) const CASEWORK: HelperSpec = HelperSpec { + binary: "casework", + component: "casework", + explicit_source: "--casework-bin or CASEWORK_BIN", + managed_env: "RSTACK_CASEWORK_BIN", +}; + +pub(crate) fn resolve_managed( + spec: HelperSpec, + explicit: Option<&Path>, +) -> Result> { + resolve_managed_with_environment( + spec, + explicit, + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(spec.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + ) +} + +fn resolve_managed_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result> { + let Some(generation_dir) = generation_dir else { + return Ok(None); + }; + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(Path::new(generation_dir)).with_context(|| { + format!( + "rstack {release} selected generation {} cannot be resolved", + Path::new(generation_dir).display() + ) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let Some(managed_path) = managed_path else { + return missing_component(spec, &release); + }; + let selected = + canonical_helper(Path::new(managed_path)).or_else(|_| missing_component(spec, &release))?; + let layout = canonical_helper(&layout_path).or_else(|_| missing_component(spec, &release))?; + if selected != layout { + bail!( + "rstack {release} provided {} outside the selected generation helper path at {}; run `rstack install {}` to repair the selected generation", + spec.managed_env, + layout_path.display(), + spec.component + ); + } + if let Some(override_path) = explicit { + let override_identity = canonical_helper(override_path).with_context(|| { + format!( + "cannot resolve {} from {}", + spec.binary, spec.explicit_source + ) + })?; + if override_identity != selected { + bail!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected generation identity", + spec.explicit_source, + override_path.display(), + spec.binary, + selected.display() + ); + } + } + Ok(Some(selected)) +} + +fn canonical_helper(path: &Path) -> Result { + if !path.is_file() { + bail!("helper is not an installed file at {}", path.display()); + } + Ok(fs::canonicalize(path)?) +} + +fn missing_component(spec: HelperSpec, release: &str) -> Result { + bail!( + "rstack {release} selected generation has no usable {} helper; run `rstack install {}` and retry, caseworkctl will not install components automatically", + spec.binary, + spec.component + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{fs, os::unix::fs::symlink}; + + fn fixture(root: &Path, spec: HelperSpec) -> (PathBuf, PathBuf) { + let generation = root.join("generation"); + let selected = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"same version\n").unwrap(); + (generation, selected) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_helper() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), BREGCTL); + let resolved = resolve_managed_with_environment( + BREGCTL, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_accepts_an_alias_of_the_selected_helper() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), BREGCTL); + let alias = root.path().join("bregctl-alias"); + symlink(&selected, &alias).unwrap(); + let resolved = resolve_managed_with_environment( + BREGCTL, + Some(&alias), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_refuses_a_same_version_helper_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), BREGCTL); + let override_path = root.path().join("other-bregctl"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + BREGCTL, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("--bregctl-bin"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + assert!(diagnostic.contains("v-test"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_refuses_a_helper_environment_outside_the_generation() { + let root = tempfile::tempdir().unwrap(); + let (generation, _selected) = fixture(root.path(), BREGCTL); + let outside = root.path().join("outside-bregctl"); + fs::write(&outside, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + BREGCTL, + None, + Some(generation.as_os_str()), + Some(outside.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("RSTACK_BREGCTL_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + } + + #[test] + fn managed_casework_resolution_names_the_environment_override() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path(), CASEWORK); + let override_path = root.path().join("other-casework"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + CASEWORK, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("CASEWORK_BIN"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_names_the_install_command_when_component_is_missing() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let error = resolve_managed_with_environment( + CASEWORK, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!( + diagnostic.contains("rstack install casework"), + "{diagnostic}" + ); + assert!(diagnostic.contains("will not install"), "{diagnostic}"); + } + + #[test] + fn unmanaged_resolution_preserves_existing_lookup() { + assert_eq!( + resolve_managed_with_environment(BREGCTL, None, None, None, None).unwrap(), + None + ); + } +} diff --git a/crates/registry-caseworkctl/tests/dev_lifecycle.rs b/crates/registry-caseworkctl/tests/dev_lifecycle.rs index a94e23264b..08f5f42be4 100644 --- a/crates/registry-caseworkctl/tests/dev_lifecycle.rs +++ b/crates/registry-caseworkctl/tests/dev_lifecycle.rs @@ -632,7 +632,7 @@ fn create_review_request_for( runtime.block_on(async { let client = CaseworkClient::new( CaseworkClientConfig::new(report["caseworkUrl"].as_str().unwrap().parse().unwrap()) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .expect("public Rust client"); let auth = || CaseworkAuth::new(&token, "requester"); diff --git a/crates/registry-caseworkctl/tests/serve.rs b/crates/registry-caseworkctl/tests/serve.rs new file mode 100644 index 0000000000..82c30ad076 --- /dev/null +++ b/crates/registry-caseworkctl/tests/serve.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_caseworkctl")) + .args(arguments) + .env("CASEWORK_BIN", binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("caseworkctl starts") +} + +#[test] +fn unmanaged_serve_preserves_runtime_arguments_streams_process_and_exit() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "casework", 17); + let capture = temp.path().join("capture"); + let child = Command::new(env!("CARGO_BIN_EXE_caseworkctl")) + .args(["serve", "--runtime-config", "/tmp/runtime.yaml"]) + .env("CASEWORK_BIN", &binary) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("caseworkctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!(output.status.code(), Some(17)); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\nserve\n") + ); +} + +#[test] +fn unmanaged_serve_falls_back_to_path_without_a_binary_override() { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), "casework", 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_caseworkctl")) + .args(["serve", "--runtime-config", "/tmp/path.yaml"]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("CASEWORK_BIN") + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("caseworkctl starts"); + + assert_eq!(output.status.code(), Some(21)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml", "serve"] + ); +} + +#[test] +fn managed_serve_uses_the_selected_generation_instead_of_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/casework/casework"); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, "casework", 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + let poisoned = runtime(&poisoned_directory, "casework", 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory.clone()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_caseworkctl")) + .args(["serve", "--runtime-config", "/tmp/managed.yaml"]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_CASEWORK_BIN", &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove("CASEWORK_BIN") + .output() + .expect("caseworkctl starts"); + + assert_eq!(output.status.code(), Some(19)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml", "serve"] + ); + assert_ne!(selected, poisoned); +} + +#[test] +fn serve_rejects_json_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "casework", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "--format", + "json", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the runtime must not start"); +} diff --git a/crates/registry-cli-docs/Cargo.toml b/crates/registry-cli-docs/Cargo.toml index e0cb34db1c..1c8ebe534b 100644 --- a/crates/registry-cli-docs/Cargo.toml +++ b/crates/registry-cli-docs/Cargo.toml @@ -29,5 +29,7 @@ registry-scheduling.workspace = true registry-schedulingctl.workspace = true registry-messaging.workspace = true registry-messagingctl.workspace = true +registry-manifest-cli.workspace = true +registry-rstack.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/registry-cli-docs/catalog.json b/crates/registry-cli-docs/catalog.json index 9f417214b6..3572d232dd 100644 --- a/crates/registry-cli-docs/catalog.json +++ b/crates/registry-cli-docs/catalog.json @@ -311,6 +311,334 @@ ], "constraints": [], "subcommands": [ + { + "name": "serve", + "invocation": "bregctl serve", + "about": "Run the production Base Registry Engine service with the selected runtime", + "long_about": null, + "usage": "bregctl serve [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file passed unchanged to the Base Registry Engine runtime. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "mcp", + "invocation": "bregctl mcp", + "about": "Operate the optional citizen-facing MCP gateway", + "long_about": null, + "usage": "bregctl mcp [OPTIONS] ", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "check", + "invocation": "bregctl mcp check", + "about": "Check the runtime file offline, reading no secret and opening no socket", + "long_about": null, + "usage": "bregctl mcp check [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file to check. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--deny-warnings", + "description": "Exit 1 when a warning is reported", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--environment", + "description": "Fill environment expressions while checking the runtime file", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "serve", + "invocation": "bregctl mcp serve", + "about": "Serve until terminated", + "long_about": null, + "usage": "bregctl mcp serve [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file for the service. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + } + ] + }, + { + "name": "review", + "invocation": "bregctl review", + "about": "Operate the optional citizen review page", + "long_about": null, + "usage": "bregctl review [OPTIONS] ", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "check", + "invocation": "bregctl review check", + "about": "Check the runtime file offline, reading no secret and opening no socket", + "long_about": null, + "usage": "bregctl review check [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file to check. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--deny-warnings", + "description": "Exit 1 when a warning is reported", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--environment", + "description": "Fill environment expressions while checking the runtime file", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "serve", + "invocation": "bregctl review serve", + "about": "Serve until terminated", + "long_about": null, + "usage": "bregctl review serve [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file for the service. A symbolic link at any component of this path is refused.", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + } + ] + }, { "name": "init", "invocation": "bregctl init", @@ -1787,9 +2115,7 @@ "description": "Casework authoring CLI from the same installation", "always_required": false, "repeatable": false, - "default_values": [ - "caseworkctl" - ], + "default_values": [], "possible_values": [], "environment": null }, @@ -5247,6 +5573,50 @@ ], "constraints": [], "subcommands": [ + { + "name": "serve", + "invocation": "caseworkctl serve", + "about": "Run the production Casework service with the selected runtime", + "long_about": null, + "usage": "caseworkctl serve [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file passed unchanged to the Casework runtime", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Write human diagnostics and reports, or one typed JSON report", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, { "name": "init", "invocation": "caseworkctl init", @@ -5365,12 +5735,10 @@ }, { "display": "--bregctl-bin ", - "description": "Matching bregctl executable used for native package verification", + "description": "Matching bregctl executable used for native package verification; otherwise use the rstack-selected helper or bregctl on PATH", "always_required": false, "repeatable": false, - "default_values": [ - "bregctl" - ], + "default_values": [], "possible_values": [], "environment": null }, @@ -5690,12 +6058,10 @@ }, { "display": "--bregctl-bin ", - "description": "Matching bregctl executable used for public project and package inspection", + "description": "Matching bregctl executable used for public project and package inspection; otherwise use the rstack-selected helper or bregctl on PATH", "always_required": false, "repeatable": false, - "default_values": [ - "bregctl" - ], + "default_values": [], "possible_values": [], "environment": null }, @@ -5781,12 +6147,10 @@ }, { "display": "--bregctl-bin ", - "description": "Matching bregctl executable used to verify the producer package", + "description": "Matching bregctl executable used to verify the producer package; otherwise use the rstack-selected helper or bregctl on PATH", "always_required": false, "repeatable": false, - "default_values": [ - "bregctl" - ], + "default_values": [], "possible_values": [], "environment": null }, @@ -6722,6 +7086,50 @@ ], "constraints": [], "subcommands": [ + { + "name": "serve", + "invocation": "coordinatorctl serve", + "about": "Run the production Coordinator service with the selected runtime", + "long_about": null, + "usage": "coordinatorctl serve [OPTIONS]", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Output format for local reports and authenticated API responses: readable text, or one JSON report", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--runtime-config ", + "description": "Runtime configuration file", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, { "name": "init", "invocation": "coordinatorctl init", @@ -7027,7 +7435,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -7443,7 +7851,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -7514,7 +7922,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -7626,7 +8034,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -7697,7 +8105,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -7777,7 +8185,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -7857,7 +8265,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -7937,7 +8345,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -7999,7 +8407,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -8070,7 +8478,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -8141,7 +8549,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -8230,7 +8638,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -8319,7 +8727,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -8401,7 +8809,7 @@ }, { "display": "--token-file ", - "description": "File containing a current access token; a relative path is read from the current directory. Token values are never printed", + "description": "Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed", "always_required": false, "repeatable": false, "default_values": [], @@ -11683,22 +12091,257 @@ "environment": null }, { - "display": "--resource ", - "description": "Exact inbound OAuth resource for this retained Evidence session. Defaults to the local gateway resource on first start", + "display": "--resource ", + "description": "Exact inbound OAuth resource for this retained Evidence session. Defaults to the local gateway resource on first start", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--target ", + "description": "Select the governed target and its complete authority. Defaults to local-target.yaml", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format. `junit` is accepted only by `test` and `fixtures run`", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json", + "junit" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + } + ] + }, + { + "name": "oid4vci", + "invocation": "evidencectl oid4vci", + "about": "Operate the optional Evidence wallet-delivery service", + "long_about": null, + "usage": "evidencectl oid4vci [OPTIONS] ", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Emit the selected command's report in this format. `junit` is accepted only by `test` and `fixtures run`", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json", + "junit" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "check", + "invocation": "evidencectl oid4vci check", + "about": "Check the wallet-delivery runtime file offline", + "long_about": null, + "usage": "evidencectl oid4vci check [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file to validate", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": "EVIDENCE_OID4VCI_RUNTIME_CONFIG" + }, + { + "display": "--deny-warnings", + "description": "Exit 1 when a warning is reported", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--environment", + "description": "Resolve runtime environment expressions and check their values", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format. `junit` is accepted only by `test` and `fixtures run`", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json", + "junit" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "inspect", + "invocation": "evidencectl oid4vci inspect", + "about": "Print the derived protocol metadata for a deployment", + "long_about": null, + "usage": "evidencectl oid4vci inspect [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file to inspect", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": "EVIDENCE_OID4VCI_RUNTIME_CONFIG" + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format. `junit` is accepted only by `test` and `fixtures run`", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json", + "junit" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "openapi", + "invocation": "evidencectl oid4vci openapi", + "about": "Render the OpenAPI 3.1 document", + "long_about": null, + "usage": "evidencectl oid4vci openapi [OPTIONS]", + "arguments": [], + "options": [ + { + "display": "--output ", + "description": "Write the document to this file", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format. `junit` is accepted only by `test` and `fixtures run`", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json", + "junit" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null - }, + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "serve", + "invocation": "evidencectl oid4vci serve", + "about": "Serve wallet-delivery endpoints until terminated", + "long_about": null, + "usage": "evidencectl oid4vci serve [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ { - "display": "--target ", - "description": "Select the governed target and its complete authority. Defaults to local-target.yaml", + "display": "--runtime-config ", + "description": "Runtime configuration file for the service", "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], - "environment": null + "environment": "EVIDENCE_OID4VCI_RUNTIME_CONFIG" }, { "display": "--format ", @@ -12384,7 +13027,7 @@ }, { "display": "--bregctl-bin ", - "description": "Public Base Registry Engine tooling binary; otherwise BREGCTL_BIN or bregctl on PATH", + "description": "Public Base Registry Engine tooling binary; otherwise the rstack-selected helper, BREGCTL_BIN, or bregctl on PATH", "always_required": false, "repeatable": false, "default_values": [], @@ -13880,44 +14523,348 @@ "environment": null } ], - "constraints": [], - "subcommands": [] + "constraints": [], + "subcommands": [] + }, + { + "name": "language-server", + "invocation": "evidencectl tooling language-server", + "about": "Run cross-file navigation over the Language Server Protocol", + "long_about": null, + "usage": "evidencectl tooling language-server [OPTIONS]", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Select human-readable output; this command provides no JSON report", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + } + ] + } + ] + }, + { + "name": "manifest", + "invocation": "manifest", + "about": "Validate, render, and publish portable registry metadata", + "long_about": null, + "usage": "manifest ", + "arguments": [], + "options": [ + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-V, --version", + "description": "Print version", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "validate", + "invocation": "manifest validate", + "about": "Validate one Registry Manifest metadata document", + "long_about": null, + "usage": "manifest validate [OPTIONS] ", + "arguments": [ + { + "display": "", + "description": "Registry Manifest metadata document to validate", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "--format ", + "description": "Report format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--deny-warnings", + "description": "Treat warnings as a domain refusal", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "validate-profiles", + "invocation": "manifest validate-profiles", + "about": "Validate profile descriptors and their declared fixtures", + "long_about": null, + "usage": "manifest validate-profiles [OPTIONS] [DIRECTORY]", + "arguments": [ + { + "display": "[DIRECTORY]", + "description": "Directory containing Registry Manifest profiles", + "always_required": false, + "repeatable": false, + "default_values": [ + "profiles" + ], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "--format ", + "description": "Report format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--deny-warnings", + "description": "Treat warnings as a domain refusal", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "render", + "invocation": "manifest render", + "about": "Render one artifact from a Registry Manifest document", + "long_about": "Render one artifact from a Registry Manifest document. The exact artifact JSON bytes are written to standard output; report --format does not apply.", + "usage": "manifest render [OPTIONS] --artifact-format ", + "arguments": [ + { + "display": "", + "description": "Registry Manifest metadata document to render", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "--artifact-format ", + "description": "Artifact to render to standard output", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [ + "catalog", + "evidence-offerings", + "evidence-offering", + "policies", + "policy", + "dcat", + "bregdcat-ap", + "cpsv-ap", + "shacl", + "json-schema", + "form-json-schema", + "ogc-records" + ], + "environment": null + }, + { + "display": "--profile ", + "description": "Declared DCAT application profile to render", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--dataset ", + "description": "Dataset whose policy or entity schema to render", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--entity ", + "description": "Entity whose JSON Schema to render", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--form ", + "description": "Form whose JSON Schema to render", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--offering ", + "description": "Evidence offering document to render", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help (see a summary with '-h')", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "publish", + "invocation": "manifest publish", + "about": "Publish a static Registry Manifest metadata bundle", + "long_about": "Publish a static Registry Manifest metadata bundle beneath --output. The command emits a separate human or JSON report and never writes artifact bytes to standard output.", + "usage": "manifest publish [OPTIONS] ", + "arguments": [ + { + "display": "", + "description": "Registry Manifest metadata document to publish", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "--output ", + "description": "Directory that receives the metadata bundle", + "always_required": false, + "repeatable": false, + "default_values": [ + "public/metadata" + ], + "possible_values": [], + "environment": null }, { - "name": "language-server", - "invocation": "evidencectl tooling language-server", - "about": "Run cross-file navigation over the Language Server Protocol", - "long_about": null, - "usage": "evidencectl tooling language-server [OPTIONS]", - "arguments": [], - "options": [ - { - "display": "--format ", - "description": "Select human-readable output; this command provides no JSON report", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } + "display": "--site-root ", + "description": "Separate site root that receives .well-known discovery files", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Report format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" ], - "constraints": [], - "subcommands": [] + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help (see a summary with '-h')", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null } - ] + ], + "constraints": [], + "subcommands": [] } ] }, @@ -14025,6 +14972,50 @@ ], "constraints": [], "subcommands": [ + { + "name": "serve", + "invocation": "messagingctl serve", + "about": "Run the production Messaging service with the selected runtime", + "long_about": null, + "usage": "messagingctl serve [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file passed unchanged to the Messaging runtime", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, { "name": "init", "invocation": "messagingctl init", @@ -14923,19 +15914,180 @@ "environment": null } ], - "constraints": [], - "subcommands": [] - } - ] - }, - { - "name": "retention", - "invocation": "messagingctl retention", - "about": "Erase what the retention periods say has expired", - "long_about": null, - "usage": "messagingctl retention [OPTIONS] ", - "arguments": [], - "options": [ + "constraints": [], + "subcommands": [] + } + ] + }, + { + "name": "retention", + "invocation": "messagingctl retention", + "about": "Erase what the retention periods say has expired", + "long_about": null, + "usage": "messagingctl retention [OPTIONS] ", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "erase-expired", + "invocation": "messagingctl retention erase-expired", + "about": "Report what had expired by --before, and erase it with --apply", + "long_about": null, + "usage": "messagingctl retention erase-expired [OPTIONS] --runtime-config --before ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--before ", + "description": "The RFC 3339 instant every retention period is counted back from. It must not be in the future", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--apply", + "description": "Erase. Without it, only report what would be erased", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + } + ] + }, + { + "name": "dev", + "invocation": "messagingctl dev", + "about": "Run an authoring project locally with PostgreSQL, Mailpit, and a mock HTTP gateway in Docker, until Ctrl-C", + "long_about": "Run an authoring project locally with PostgreSQL, Mailpit, and a mock HTTP gateway in Docker, until Ctrl-C. The session pulls and runs the pinned images docker.io/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 and ghcr.io/axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d, the Docker Official Images reference and upstream GHCR mirror of postgres:17.11 and axllent/mailpit:v1.31.2 at those digests. Each publishes on 127.0.0.1 only, and the session removes both when it stops.", + "usage": "messagingctl dev [OPTIONS] [PROJECT]\n messagingctl dev ", + "arguments": [ + { + "display": "[PROJECT]", + "description": "The package directory to run, as `messagingctl init` writes it", + "always_required": false, + "repeatable": false, + "default_values": [ + "." + ], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "--port ", + "description": "The runtime's loopback port", + "always_required": false, + "repeatable": false, + "default_values": [ + "8107" + ], + "possible_values": [], + "environment": "MESSAGINGCTL_DEV_PORT" + }, + { + "display": "--metrics-port ", + "description": "The runtime's loopback metrics port", + "always_required": false, + "repeatable": false, + "default_values": [ + "9107" + ], + "possible_values": [], + "environment": "MESSAGINGCTL_DEV_METRICS_PORT" + }, + { + "display": "--mock-latency-ms ", + "description": "How long the mock gateway takes to answer each send, in milliseconds", + "always_required": false, + "repeatable": false, + "default_values": [ + "200" + ], + "possible_values": [], + "environment": null + }, + { + "display": "--issuer-project ", + "description": "Borrow pre-registered clients from this Base Registry Engine (BReg) development project", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--resource ", + "description": "The exact Messaging audience registered at the borrowed issuer", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -14952,7 +16104,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -14963,25 +16115,15 @@ "constraints": [], "subcommands": [ { - "name": "erase-expired", - "invocation": "messagingctl retention erase-expired", - "about": "Report what had expired by --before, and erase it with --apply", + "name": "token", + "invocation": "messagingctl dev token", + "about": "Write a bearer header file for a client an access profile names, using the running session's local key or borrowed issuer", "long_about": null, - "usage": "messagingctl retention erase-expired [OPTIONS] --runtime-config --before ", - "arguments": [], - "options": [ - { - "display": "--runtime-config ", - "description": "Runtime configuration file", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, + "usage": "messagingctl dev token [OPTIONS] [PROJECT]", + "arguments": [ { - "display": "--before ", - "description": "The RFC 3339 instant every retention period is counted back from. It must not be in the future", + "display": "", + "description": "A client that one access profile in messaging.yaml names", "always_required": true, "repeatable": false, "default_values": [], @@ -14989,14 +16131,18 @@ "environment": null }, { - "display": "--apply", - "description": "Erase. Without it, only report what would be erased", + "display": "[PROJECT]", + "description": "The package directory the session runs", "always_required": false, "repeatable": false, - "default_values": [], + "default_values": [ + "." + ], "possible_values": [], "environment": null - }, + } + ], + "options": [ { "display": "--format ", "description": "Emit the selected command's report in this format", @@ -15025,72 +16171,211 @@ "subcommands": [] } ] + } + ] + }, + { + "name": "registry-render", + "invocation": "registry-render", + "about": "Governed, byte-stable PDF documents from registry data, rendered with Typst.", + "long_about": null, + "usage": "registry-render ", + "arguments": [], + "options": [ + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null }, { - "name": "dev", - "invocation": "messagingctl dev", - "about": "Run an authoring project locally with PostgreSQL, Mailpit, and a mock HTTP gateway in Docker, until Ctrl-C", - "long_about": "Run an authoring project locally with PostgreSQL, Mailpit, and a mock HTTP gateway in Docker, until Ctrl-C. The session pulls and runs the pinned images docker.io/library/postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675 and ghcr.io/axllent/mailpit:v1.31.2@sha256:74d609a42ec279aa63c6b4622a6fa9b5408d1ad5b1d76a1c4be40a265ce0863d, the Docker Official Images reference and upstream GHCR mirror of postgres:17.11 and axllent/mailpit:v1.31.2 at those digests. Each publishes on 127.0.0.1 only, and the session removes both when it stops.", - "usage": "messagingctl dev [OPTIONS] [PROJECT]\n messagingctl dev ", + "display": "-V, --version", + "description": "Print version", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "init", + "invocation": "registry-render init", + "about": "Scaffold a working starter bundle", + "long_about": null, + "usage": "registry-render init [OPTIONS] ", "arguments": [ { - "display": "[PROJECT]", - "description": "The package directory to run, as `messagingctl init` writes it", + "display": "", + "description": "Target directory (created if absent)", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "--labels ", + "description": "Label locales for the starter document. Comma-separated or repeated: `--labels en,fr` and `--labels en --labels fr` match", + "always_required": false, + "repeatable": true, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", "always_required": false, "repeatable": false, - "default_values": [ - "." - ], + "default_values": [], "possible_values": [], "environment": null } ], + "constraints": [], + "subcommands": [] + }, + { + "name": "check", + "invocation": "registry-render check", + "about": "Check a bundle or package and a runtime file offline, reporting every finding", + "long_about": "Check a bundle or package and a runtime file offline, reporting every finding. Exits 0 when nothing is refused, 1 when something is refused or, under --deny-warnings, a warning is reported, 2 when the command line is invalid, and 3 when an input cannot be read.", + "usage": "registry-render check [OPTIONS]", + "arguments": [], "options": [ { - "display": "--port ", - "description": "The runtime's loopback port", + "display": "--bundle ", + "description": "Authored bundle or package directory (default: the current directory, unless only --runtime-config is given)", "always_required": false, "repeatable": false, - "default_values": [ - "8107" - ], + "default_values": [], "possible_values": [], - "environment": "MESSAGINGCTL_DEV_PORT" + "environment": null }, { - "display": "--metrics-port ", - "description": "The runtime's loopback metrics port", + "display": "--runtime-config ", + "description": "Runtime configuration file", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--environment", + "description": "Fill `${NAME}` expressions in the runtime file from the process environment and check the values they produce. Without it, each expression is checked by syntax and position only", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--require-audit-under ", + "description": "Prove that the runtime file's audit file resolves under this absolute directory, the one the deployment mounts as persistent storage. A stdout audit destination is refused. Requires --runtime-config", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", "always_required": false, "repeatable": false, "default_values": [ - "9107" + "human" + ], + "possible_values": [ + "human", + "json" ], + "environment": null + }, + { + "display": "--deny-warnings", + "description": "Exit 1 when a warning is reported", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help (see a summary with '-h')", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [ + { + "kind": "requires-all", + "when": "--environment", + "arguments": [ + "--runtime-config " + ] + }, + { + "kind": "requires-all", + "when": "--require-audit-under ", + "arguments": [ + "--runtime-config " + ] + } + ], + "subcommands": [] + }, + { + "name": "validate", + "invocation": "registry-render validate", + "about": "Dry-run request data against a document's schema, without rendering", + "long_about": null, + "usage": "registry-render validate [OPTIONS] --bundle --type --data ", + "arguments": [], + "options": [ + { + "display": "--bundle ", + "description": "Authored bundle or package directory", + "always_required": true, + "repeatable": false, + "default_values": [], "possible_values": [], - "environment": "MESSAGINGCTL_DEV_METRICS_PORT" + "environment": null }, { - "display": "--mock-latency-ms ", - "description": "How long the mock gateway takes to answer each send, in milliseconds", - "always_required": false, + "display": "--type ", + "description": "Document type id, as declared in the manifest", + "always_required": true, "repeatable": false, - "default_values": [ - "200" - ], + "default_values": [], "possible_values": [], "environment": null }, { - "display": "--issuer-project ", - "description": "Borrow pre-registered clients from this Base Registry Engine (BReg) development project", - "always_required": false, + "display": "--data ", + "description": "JSON file holding the bare `data` object", + "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--resource ", - "description": "The exact Messaging audience registered at the borrowed issuer", + "display": "--locale ", + "description": "Primary locale, recorded in the envelope; must be one the document declares. The template receives every label table either way", "always_required": false, "repeatable": false, "default_values": [], @@ -15098,22 +16383,17 @@ "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", + "display": "--json", + "description": "Machine-readable result on stdout (failures: problem+json on stderr, typed exit code)", "always_required": false, "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], + "default_values": [], + "possible_values": [], "environment": null }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -15122,119 +16402,39 @@ } ], "constraints": [], - "subcommands": [ - { - "name": "token", - "invocation": "messagingctl dev token", - "about": "Write a bearer header file for a client an access profile names, using the running session's local key or borrowed issuer", - "long_about": null, - "usage": "messagingctl dev token [OPTIONS] [PROJECT]", - "arguments": [ - { - "display": "", - "description": "A client that one access profile in messaging.yaml names", - "always_required": true, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - }, - { - "display": "[PROJECT]", - "description": "The package directory the session runs", - "always_required": false, - "repeatable": false, - "default_values": [ - "." - ], - "possible_values": [], - "environment": null - } - ], - "options": [ - { - "display": "--format ", - "description": "Emit the selected command's report in this format", - "always_required": false, - "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], - "environment": null - }, - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [] - } - ] - } - ] - }, - { - "name": "registry-render", - "invocation": "registry-render", - "about": "Governed, byte-stable PDF documents from registry data, rendered with Typst.", - "long_about": null, - "usage": "registry-render ", - "arguments": [], - "options": [ - { - "display": "-h, --help", - "description": "Print help", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null + "subcommands": [] }, { - "display": "-V, --version", - "description": "Print version", - "always_required": false, - "repeatable": false, - "default_values": [], - "possible_values": [], - "environment": null - } - ], - "constraints": [], - "subcommands": [ - { - "name": "init", - "invocation": "registry-render init", - "about": "Scaffold a working starter bundle", + "name": "package", + "invocation": "registry-render package", + "about": "Build a new deployment package with the shared checksum envelope", "long_about": null, - "usage": "registry-render init [OPTIONS] ", - "arguments": [ + "usage": "registry-render package [OPTIONS] --bundle --output ", + "arguments": [], + "options": [ { - "display": "", - "description": "Target directory (created if absent)", + "display": "--bundle ", + "description": "Authored bundle directory", "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null - } - ], - "options": [ + }, { - "display": "--labels ", - "description": "Label locales for the starter document. Comma-separated or repeated: `--labels en,fr` and `--labels en --labels fr` match", + "display": "--output ", + "description": "New package directory; it must not exist yet", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--revision ", + "description": "One printable line recorded in REVISION and covered by the digest", "always_required": false, - "repeatable": true, + "repeatable": false, "default_values": [], "possible_values": [], "environment": null @@ -15253,16 +16453,43 @@ "subcommands": [] }, { - "name": "check", - "invocation": "registry-render check", - "about": "Check a bundle or package and a runtime file offline, reporting every finding", - "long_about": "Check a bundle or package and a runtime file offline, reporting every finding. Exits 0 when nothing is refused, 1 when something is refused or, under --deny-warnings, a warning is reported, 2 when the command line is invalid, and 3 when an input cannot be read.", - "usage": "registry-render check [OPTIONS]", + "name": "compile", + "invocation": "registry-render compile", + "about": "Render one document offline", + "long_about": null, + "usage": "registry-render compile [OPTIONS] --bundle --type --data --out ", "arguments": [], "options": [ { "display": "--bundle ", - "description": "Authored bundle or package directory (default: the current directory, unless only --runtime-config is given)", + "description": "Authored bundle or package directory", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--type ", + "description": "Document type id, as declared in the manifest", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--data ", + "description": "JSON file holding the bare `data` object", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--locale ", + "description": "Primary locale, recorded in the envelope; must be one the document declares. The template receives every label table either way", "always_required": false, "repeatable": false, "default_values": [], @@ -15270,8 +16497,8 @@ "environment": null }, { - "display": "--runtime-config ", - "description": "Runtime configuration file", + "display": "--issued-at ", + "description": "Issuance time; the document's claim, and the world clock", "always_required": false, "repeatable": false, "default_values": [], @@ -15279,8 +16506,8 @@ "environment": null }, { - "display": "--environment", - "description": "Fill `${NAME}` expressions in the runtime file from the process environment and check the values they produce. Without it, each expression is checked by syntax and position only", + "display": "--now", + "description": "Use the current wall clock as issuance time (nondeterministic; loud opt-in for throwaway previews)", "always_required": false, "repeatable": false, "default_values": [], @@ -15288,8 +16515,17 @@ "environment": null }, { - "display": "--require-audit-under ", - "description": "Prove that the runtime file's audit file resolves under this absolute directory, the one the deployment mounts as persistent storage. A stdout audit destination is refused. Requires --runtime-config", + "display": "--asset ", + "description": "Asset `name=path`, path holding base64 text", + "always_required": false, + "repeatable": true, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--strict", + "description": "Refuse warnings (missing glyphs and friends)", "always_required": false, "repeatable": false, "default_values": [], @@ -15297,22 +16533,44 @@ "environment": null }, { - "display": "--format ", - "description": "Emit the selected command's report in this format", + "display": "--timeout ", + "description": "Wall-clock budget for the render in seconds; the render is killed at this bound (the same supervised-worker wall serve uses)", "always_required": false, "repeatable": false, - "default_values": [ - "human" - ], - "possible_values": [ - "human", - "json" - ], + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--watch", + "description": "Keep rendering on bundle changes (authoring loop; raw source directories are fine, poll-based). Never returns, so it refuses `--emit-envelope` rather than ignoring it", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--out ", + "description": "Output PDF path", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--json", + "description": "Machine-readable result on stdout", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], "environment": null }, { - "display": "--deny-warnings", - "description": "Exit 1 when a warning is reported", + "display": "--emit-envelope ", + "description": "Write the exact canonical envelope bytes injected into the template to this path (merge-gate and debugging aid)", "always_required": false, "repeatable": false, "default_values": [], @@ -15321,7 +16579,7 @@ }, { "display": "-h, --help", - "description": "Print help (see a summary with '-h')", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], @@ -15331,33 +16589,35 @@ ], "constraints": [ { - "kind": "requires-all", - "when": "--environment", + "kind": "mutually-exclusive", + "when": null, "arguments": [ - "--runtime-config " + "--emit-envelope ", + "--watch" ] }, { - "kind": "requires-all", - "when": "--require-audit-under ", + "kind": "mutually-exclusive", + "when": null, "arguments": [ - "--runtime-config " + "--issued-at ", + "--now" ] } ], "subcommands": [] }, { - "name": "validate", - "invocation": "registry-render validate", - "about": "Dry-run request data against a document's schema, without rendering", + "name": "serve", + "invocation": "registry-render serve", + "about": "Serve the HTTP rendering API (see the runtime YAML)", "long_about": null, - "usage": "registry-render validate [OPTIONS] --bundle --type --data ", + "usage": "registry-render serve --runtime-config ", "arguments": [], "options": [ { - "display": "--bundle ", - "description": "Authored bundle or package directory", + "display": "--runtime-config ", + "description": "Runtime configuration file", "always_required": true, "repeatable": false, "default_values": [], @@ -15365,8 +16625,29 @@ "environment": null }, { - "display": "--type ", - "description": "Document type id, as declared in the manifest", + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "healthcheck", + "invocation": "registry-render healthcheck", + "about": "Probe a running server's /health", + "long_about": null, + "usage": "registry-render healthcheck --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file", "always_required": true, "repeatable": false, "default_values": [], @@ -15374,17 +16655,124 @@ "environment": null }, { - "display": "--data ", - "description": "JSON file holding the bare `data` object", - "always_required": true, + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + } + ] + }, + { + "name": "rstack", + "invocation": "rstack", + "about": "Registry Stack command and component manager", + "long_about": null, + "usage": "rstack [OPTIONS] [COMMAND]", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", + "always_required": false, + "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-V, --version", + "description": "Print version", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [ + { + "name": "components", + "invocation": "rstack components", + "about": "List the selected release and component availability", + "long_about": null, + "usage": "rstack components [OPTIONS] ", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", + "always_required": false, + "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--locale ", - "description": "Primary locale, recorded in the envelope; must be one the document declares. The template receives every label table either way", + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", "always_required": false, "repeatable": false, "default_values": [], @@ -15392,8 +16780,8 @@ "environment": null }, { - "display": "--json", - "description": "Machine-readable result on stdout (failures: problem+json on stderr, typed exit code)", + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", "always_required": false, "repeatable": false, "default_values": [], @@ -15411,37 +16799,133 @@ } ], "constraints": [], - "subcommands": [] + "subcommands": [ + { + "name": "list", + "invocation": "rstack components list", + "about": "List every known product and its selected-release status", + "long_about": null, + "usage": "rstack components list [OPTIONS]", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", + "always_required": false, + "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + } + ] }, { - "name": "package", - "invocation": "registry-render package", - "about": "Build a new deployment package with the shared checksum envelope", + "name": "install", + "invocation": "rstack install", + "about": "Install one or more components at the selected release", "long_about": null, - "usage": "registry-render package [OPTIONS] --bundle --output ", - "arguments": [], + "usage": "rstack install [OPTIONS] [COMPONENT]...", + "arguments": [ + { + "display": "[COMPONENT]", + "description": "Product to install", + "always_required": false, + "repeatable": true, + "default_values": [], + "possible_values": [], + "environment": null + } + ], "options": [ { - "display": "--bundle ", - "description": "Authored bundle directory", - "always_required": true, + "display": "--all", + "description": "Install every product available for the selected platform", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", + "always_required": false, + "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--output ", - "description": "New package directory; it must not exist yet", - "always_required": true, + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--revision ", - "description": "One printable line recorded in REVISION and covered by the digest", + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", "always_required": false, "repeatable": false, "default_values": [], @@ -15462,16 +16946,16 @@ "subcommands": [] }, { - "name": "compile", - "invocation": "registry-render compile", - "about": "Render one document offline", + "name": "update", + "invocation": "rstack update", + "about": "Update the launcher and installed components to one exact release", "long_about": null, - "usage": "registry-render compile [OPTIONS] --bundle --type --data --out ", + "usage": "rstack update [OPTIONS] --release ", "arguments": [], "options": [ { - "display": "--bundle ", - "description": "Authored bundle or package directory", + "display": "--release ", + "description": "Exact release tag to activate", "always_required": true, "repeatable": false, "default_values": [], @@ -15479,26 +16963,40 @@ "environment": null }, { - "display": "--type ", - "description": "Document type id, as declared in the manifest", - "always_required": true, + "display": "--preview", + "description": "Check the target release and report changes without activation", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--data ", - "description": "JSON file holding the bare `data` object", - "always_required": true, + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", + "always_required": false, + "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--locale ", - "description": "Primary locale, recorded in the envelope; must be one the document declares. The template receives every label table either way", + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", "always_required": false, "repeatable": false, "default_values": [], @@ -15506,8 +17004,8 @@ "environment": null }, { - "display": "--issued-at ", - "description": "Issuance time; the document's claim, and the world clock", + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", "always_required": false, "repeatable": false, "default_values": [], @@ -15515,8 +17013,39 @@ "environment": null }, { - "display": "--now", - "description": "Use the current wall clock as issuance time (nondeterministic; loud opt-in for throwaway previews)", + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "uninstall", + "invocation": "rstack uninstall", + "about": "Remove one or more installed components", + "long_about": null, + "usage": "rstack uninstall [OPTIONS] [COMPONENT]...", + "arguments": [ + { + "display": "[COMPONENT]", + "description": "Product to remove", + "always_required": false, + "repeatable": true, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "--all", + "description": "Remove the complete managed installation", "always_required": false, "repeatable": false, "default_values": [], @@ -15524,17 +17053,31 @@ "environment": null }, { - "display": "--asset ", - "description": "Asset `name=path`, path holding base64 text", + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", "always_required": false, "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, + "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--strict", - "description": "Refuse warnings (missing glyphs and friends)", + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", "always_required": false, "repeatable": false, "default_values": [], @@ -15542,8 +17085,8 @@ "environment": null }, { - "display": "--timeout ", - "description": "Wall-clock budget for the render in seconds; the render is killed at this bound (the same supervised-worker wall serve uses)", + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", "always_required": false, "repeatable": false, "default_values": [], @@ -15551,26 +17094,52 @@ "environment": null }, { - "display": "--watch", - "description": "Keep rendering on bundle changes (authoring loop; raw source directories are fine, poll-based). Never returns, so it refuses `--emit-envelope` rather than ignoring it", + "display": "-h, --help", + "description": "Print help", "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, + { + "name": "cleanup", + "invocation": "rstack cleanup", + "about": "Remove inactive, unused installation generations", + "long_about": null, + "usage": "rstack cleanup [OPTIONS]", + "arguments": [], + "options": [ + { + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", + "always_required": false, + "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null }, { - "display": "--out ", - "description": "Output PDF path", - "always_required": true, + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], "environment": null }, { - "display": "--json", - "description": "Machine-readable result on stdout", + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", "always_required": false, "repeatable": false, "default_values": [], @@ -15578,8 +17147,8 @@ "environment": null }, { - "display": "--emit-envelope ", - "description": "Write the exact canonical envelope bytes injected into the template to this path (merge-gate and debugging aid)", + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", "always_required": false, "repeatable": false, "default_values": [], @@ -15596,38 +17165,53 @@ "environment": null } ], - "constraints": [ - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--emit-envelope ", - "--watch" - ] - }, - { - "kind": "mutually-exclusive", - "when": null, - "arguments": [ - "--issued-at ", - "--now" - ] - } - ], + "constraints": [], "subcommands": [] }, { - "name": "serve", - "invocation": "registry-render serve", - "about": "Serve the HTTP rendering API (see the runtime YAML)", + "name": "doctor", + "invocation": "rstack doctor", + "about": "Check selected binaries, paths, and component availability", "long_about": null, - "usage": "registry-render serve --runtime-config ", + "usage": "rstack doctor [OPTIONS]", "arguments": [], "options": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file", - "always_required": true, + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", + "always_required": false, + "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", + "always_required": false, "repeatable": false, "default_values": [], "possible_values": [], @@ -15647,21 +17231,63 @@ "subcommands": [] }, { - "name": "healthcheck", - "invocation": "registry-render healthcheck", - "about": "Probe a running server's /health", + "name": "completions", + "invocation": "rstack completions", + "about": "Generate shell completion script", "long_about": null, - "usage": "registry-render healthcheck --runtime-config ", - "arguments": [], - "options": [ + "usage": "rstack completions [OPTIONS] ", + "arguments": [ { - "display": "--runtime-config ", - "description": "Runtime configuration file", + "display": "", + "description": "Shell whose completion script to generate", "always_required": true, "repeatable": false, "default_values": [], "possible_values": [], "environment": null + } + ], + "options": [ + { + "display": "--format ", + "description": "Report encoding. Defaults to human even when stdout is piped", + "always_required": false, + "repeatable": true, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "--install-root ", + "description": "Installation generation storage directory", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--bin-dir ", + "description": "Directory for the single public rstack executable link", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--asset-dir ", + "description": "Directory of already downloaded, verified release assets", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null }, { "display": "-h, --help", @@ -15782,6 +17408,50 @@ ], "constraints": [], "subcommands": [ + { + "name": "serve", + "invocation": "schedulingctl serve", + "about": "Run the production Scheduling service with the selected runtime", + "long_about": null, + "usage": "schedulingctl serve [OPTIONS] --runtime-config ", + "arguments": [], + "options": [ + { + "display": "--runtime-config ", + "description": "Runtime configuration file passed unchanged to the Scheduling runtime", + "always_required": true, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + }, + { + "display": "--format ", + "description": "Emit the selected command's report in this format", + "always_required": false, + "repeatable": false, + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], + "environment": null + }, + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, { "name": "init", "invocation": "schedulingctl init", @@ -16354,7 +18024,7 @@ }, { "display": "--limit ", - "description": "Most intents to list", + "description": "Most intents to list, from 1 through 200", "always_required": false, "repeatable": false, "default_values": [ diff --git a/crates/registry-cli-docs/src/lib.rs b/crates/registry-cli-docs/src/lib.rs index 5dcfea17e9..51f59f9849 100644 --- a/crates/registry-cli-docs/src/lib.rs +++ b/crates/registry-cli-docs/src/lib.rs @@ -22,6 +22,8 @@ pub fn catalog() -> Catalog { command_reference(registry_schedulingctl::command(), None, None), command_reference(registry_messaging::runtime::command(), None, None), command_reference(registry_messagingctl::command(), None, None), + command_reference(registry_manifest_cli::command(), None, None), + command_reference(registry_rstack::command(), None, None), command_reference(registry_breg::command(), None, None), command_reference(registry_breg_mcp::command(), None, None), command_reference(registry_breg_review::command(), None, None), @@ -125,15 +127,86 @@ mod tests { "evidence", "evidence-oid4vci", "evidencectl", + "manifest", "messaging", "messagingctl", "registry-render", + "rstack", "scheduling", "schedulingctl", ] ); } + #[test] + fn launcher_publishes_the_managed_installation_commands() { + let catalog = catalog(); + for invocation in [ + "rstack components list", + "rstack install", + "rstack update", + "rstack uninstall", + "rstack cleanup", + "rstack doctor", + "rstack help", + "rstack completions", + ] { + assert!(!find_command(&catalog.binaries, invocation).usage.is_empty()); + } + let root = find_command(&catalog.binaries, "rstack"); + assert!(root.options.iter().any(|option| { + option.display == "--format " + && option.default_values == ["human"] + && option.possible_values == ["human", "json"] + })); + } + + #[test] + fn manifest_publishes_report_and_artifact_modes_from_its_canonical_tree() { + let catalog = catalog(); + let render = find_command(&catalog.binaries, "manifest render"); + let artifact_format = render + .options + .iter() + .find(|option| option.display.starts_with("--artifact-format ")) + .expect("render artifact selector"); + assert!(artifact_format.always_required); + assert_eq!( + artifact_format.possible_values, + [ + "catalog", + "evidence-offerings", + "evidence-offering", + "policies", + "policy", + "dcat", + "bregdcat-ap", + "cpsv-ap", + "shacl", + "json-schema", + "form-json-schema", + "ogc-records", + ] + ); + assert!(!render + .options + .iter() + .any(|option| option.display.starts_with("--format "))); + assert!(render.long_about.as_deref().is_some_and(|about| { + about.contains("exact artifact JSON bytes") && about.contains("does not apply") + })); + + let publish = find_command(&catalog.binaries, "manifest publish"); + assert!(publish.options.iter().any(|option| { + option.display == "--output " && option.default_values == ["public/metadata"] + })); + assert!(publish.options.iter().any(|option| { + option.display == "--format " + && option.default_values == ["human"] + && option.possible_values == ["human", "json"] + })); + } + #[test] fn coordinator_publishes_authoring_and_authenticated_recovery_commands() { let catalog = catalog(); diff --git a/crates/registry-coordinator-client-node/README.md b/crates/registry-coordinator-client-node/README.md index f7b9d02574..6c8452549c 100644 --- a/crates/registry-coordinator-client-node/README.md +++ b/crates/registry-coordinator-client-node/README.md @@ -10,7 +10,7 @@ chosen key. A fresh start must match the active revision, or it returns `409 workflow.revision-mismatch`. Retain the original identity, key, revision, and input when recovering an unknown admission, including after activation changes. The binding never acquires a token or invents a key. `start` resends an admission whose outcome is unknown, -identically and under the caller's same key, at most `maxMutationRetries` times +identically and under the caller's same key, at most `maximumMutationRetries` times (default 2, at most 2, 0 disables); a 4xx refusal is never resent. A key is scoped to the caller and the workflow, so the same key under two workflows starts two runs, and an allowed workflow the deployment does not serve is refused with 404 diff --git a/crates/registry-coordinator-client-node/__test__/facade.test-d.ts b/crates/registry-coordinator-client-node/__test__/facade.test-d.ts index 16091c1600..45152934d1 100644 --- a/crates/registry-coordinator-client-node/__test__/facade.test-d.ts +++ b/crates/registry-coordinator-client-node/__test__/facade.test-d.ts @@ -12,7 +12,7 @@ import { const client = new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', - requestTimeoutMilliseconds: 5_000, + attemptTimeoutMilliseconds: 5_000, }) const started: Promise> = client.start( @@ -63,10 +63,14 @@ if (error.kind === 'problem') { // @ts-expect-error Coordinator start requires a caller-selected idempotency key. client.start('header.payload.signature', { workflow: 'decision-follow-up', "workflowRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", input: {} }) -const once = new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', maxMutationRetries: 0 }) +const once = new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', maximumMutationRetries: 0 }) void once // @ts-expect-error The retry count is a number, never a string. -new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', maxMutationRetries: '2' }) +new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', maximumMutationRetries: '2' }) +// @ts-expect-error null is a value, not omission, for configuration members. +new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', attemptTimeoutMilliseconds: null }) +// @ts-expect-error trusted roots are PEM text, not binary data. +new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', trustedRootCertificates: Buffer.from('not PEM text') }) const workflow: Promise> = client.workflow('token', 'decision-follow-up') declare const contract: WorkflowContract diff --git a/crates/registry-coordinator-client-node/__test__/facade.test.js b/crates/registry-coordinator-client-node/__test__/facade.test.js index f151ecb812..bf8a1c193c 100644 --- a/crates/registry-coordinator-client-node/__test__/facade.test.js +++ b/crates/registry-coordinator-client-node/__test__/facade.test.js @@ -568,7 +568,7 @@ test('a refused list, retry, or cancellation keeps its status and code', async ( test('retrySame and cancel are sent once even when their outcome is unknown', async (context) => { const { baseUrl, requests } = await serve(context, scripted([UNAVAILABLE, { status: 200, document: RETRY_SCHEDULED }])); const { CoordinatorClient } = require('../client'); - const client = new CoordinatorClient({ baseUrl, maxMutationRetries: 2 }); + const client = new CoordinatorClient({ baseUrl, maximumMutationRetries: 2 }); await assert.rejects(client.retrySame('secret', RUN_ID, 'operator reviewed the inspection'), (error) => { assert.equal(error.kind, 'problem'); @@ -655,7 +655,7 @@ test('an unknown admission outcome is resent under the same key and body', async test('a retry ceiling of zero sends an admission once and reports the outcome unknown', async (context) => { const { baseUrl, requests } = await serve(context, scripted([UNAVAILABLE, { status: 200, document: STATUS }])); const { CoordinatorClient, CoordinatorClientError } = require('../client'); - const client = new CoordinatorClient({ baseUrl, maxMutationRetries: 0 }); + const client = new CoordinatorClient({ baseUrl, maximumMutationRetries: 0 }); await assert.rejects(client.start('one-call-secret', 'key-1', { workflow: 'decision-follow-up', "workflowRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", input: {} }), (error) => { assert.ok(error instanceof CoordinatorClientError); @@ -671,7 +671,7 @@ test('a retry ceiling of zero sends an admission once and reports the outcome un test('a 4xx refusal of an admission is never resent and reports the outcome known', async (context) => { const { baseUrl, requests } = await serve(context, scripted([CONFLICT, { status: 200, document: STATUS }])); const { CoordinatorClient } = require('../client'); - const client = new CoordinatorClient({ baseUrl, maxMutationRetries: 2 }); + const client = new CoordinatorClient({ baseUrl, maximumMutationRetries: 2 }); await assert.rejects(client.start('one-call-secret', 'key-1', { workflow: 'decision-follow-up', "workflowRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", input: {} }), (error) => { assert.equal(error.kind, 'problem'); @@ -689,7 +689,7 @@ test('a workflow the deployment does not serve is refused once with a known outc { status: 200, document: STATUS }, ])); const { CoordinatorClient, CoordinatorClientError } = require('../client'); - const client = new CoordinatorClient({ baseUrl, maxMutationRetries: 2 }); + const client = new CoordinatorClient({ baseUrl, maximumMutationRetries: 2 }); await assert.rejects(client.start('one-call-secret', 'key-1', { workflow: 'decision-follow-up', "workflowRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", input: {} }), (error) => { assert.ok(error instanceof CoordinatorClientError); @@ -706,7 +706,7 @@ test('a workflow the deployment does not serve is refused once with a known outc test('a run of a workflow outside the caller policy reads as absent on every run action', async (context) => { const { baseUrl, requests } = await serve(context, () => problem(404, 'run.not-visible')); const { CoordinatorClient, CoordinatorClientError } = require('../client'); - const client = new CoordinatorClient({ baseUrl, maxMutationRetries: 2 }); + const client = new CoordinatorClient({ baseUrl, maximumMutationRetries: 2 }); for (const call of [ () => client.status('secret', RUN_ID), @@ -754,13 +754,13 @@ test('a refusal that is not the exact problem document is a protocol failure', a test('a retry ceiling outside zero to two is a configuration error', () => { const { CoordinatorClient, CoordinatorClientError } = require('../client'); - for (const maxMutationRetries of [0, 1, 2, null]) { - assert.ok(new CoordinatorClient({ baseUrl: 'https://coordinator.example/', maxMutationRetries })); + for (const maximumMutationRetries of [0, 1, 2]) { + assert.ok(new CoordinatorClient({ baseUrl: 'https://coordinator.example/', maximumMutationRetries })); } - for (const maxMutationRetries of [3, 255, 256, -1, 1.5, '1']) { - assert.throws(() => new CoordinatorClient({ baseUrl: 'https://coordinator.example/', maxMutationRetries }), (error) => { - assert.ok(error instanceof CoordinatorClientError, `${maxMutationRetries}`); - assert.equal(error.kind, 'configuration', `${maxMutationRetries}`); + for (const maximumMutationRetries of [3, 255, 256, -1, 1.5, '1', true, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => new CoordinatorClient({ baseUrl: 'https://coordinator.example/', maximumMutationRetries }), (error) => { + assert.ok(error instanceof CoordinatorClientError, `${maximumMutationRetries}`); + assert.equal(error.kind, 'configuration', `${maximumMutationRetries}`); assert.equal(error.outcomeUnknown, false); return true; }); @@ -769,7 +769,7 @@ test('a retry ceiling outside zero to two is a configuration error', () => { test('a configuration member outside the declared set is a configuration error', () => { const { CoordinatorClient, CoordinatorClientError } = require('../client'); - for (const member of ['maxMutationRetry', 'requestTimeoutMs', 'authorization']) { + for (const member of ['maxMutationRetries', 'maximumMutationRetry', 'requestTimeoutMilliseconds', 'attemptTimeoutMs', 'maxResponseBytes', 'authorization']) { assert.throws(() => new CoordinatorClient({ baseUrl: 'https://coordinator.example/', [member]: 0 }), (error) => { assert.ok(error instanceof CoordinatorClientError, member); assert.equal(error.kind, 'configuration', member); @@ -778,6 +778,44 @@ test('a configuration member outside the declared set is a configuration error', } }); +test('null is not an omission for non-nullable JavaScript configuration', () => { + const { CoordinatorClient, CoordinatorClientError } = require('../client'); + for (const member of [ + 'attemptTimeoutMilliseconds', + 'connectTimeoutMilliseconds', + 'maximumResponseBytes', + 'userAgent', + 'trustedRootCertificates', + 'maximumMutationRetries', + ]) { + assert.throws( + () => new CoordinatorClient({ baseUrl: 'https://coordinator.example/', [member]: null }), + (error) => { + assert.ok(error instanceof CoordinatorClientError, member); + assert.equal(error.kind, 'configuration', member); + assert.equal(error.outcomeUnknown, false, member); + return true; + }, + ); + } +}); + +test('trusted root certificates accept PEM text, not binary data', () => { + const { CoordinatorClient, CoordinatorClientError } = require('../client'); + assert.throws( + () => new CoordinatorClient({ + baseUrl: 'https://coordinator.example/', + trustedRootCertificates: Buffer.from('not PEM text'), + }), + (error) => { + assert.ok(error instanceof CoordinatorClientError); + assert.equal(error.kind, 'configuration'); + assert.equal(error.outcomeUnknown, false); + return true; + }, + ); +}); + test('workflow discovery is a bounded read under the exact client contract', async (context) => { const contract = { workflow: 'decision-follow-up', workflowRevision: STATUS.workflowRevision, input: { type: 'object' }, outcomes: { accepted: { type: 'object' } } }; const { baseUrl, requests } = await serve(context, () => ({ status: 200, document: contract })); diff --git a/crates/registry-coordinator-client-node/__test__/native.test-d.ts b/crates/registry-coordinator-client-node/__test__/native.test-d.ts index 3c59e2a200..f99f6562bc 100644 --- a/crates/registry-coordinator-client-node/__test__/native.test-d.ts +++ b/crates/registry-coordinator-client-node/__test__/native.test-d.ts @@ -10,7 +10,7 @@ import type { const client = new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', - requestTimeoutMilliseconds: 5_000, + attemptTimeoutMilliseconds: 5_000, }) declare const contract: WorkflowContract @@ -53,9 +53,9 @@ void [started, status, inspected, reconciled, listed, attention, retried, cancel // @ts-expect-error Coordinator start requires a caller-selected idempotency key. client.start('header.payload.signature', { workflow: 'decision-follow-up', "workflowRevision": "sha256:0000000000000000000000000000000000000000000000000000000000000000", input: {} }) -const once = new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', maxMutationRetries: 0 }) +const once = new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', maximumMutationRetries: 0 }) void once // @ts-expect-error The retry count is a number, never a string. -new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', maxMutationRetries: '2' }) +new CoordinatorClient({ baseUrl: 'https://coordinator.example.invalid/', maximumMutationRetries: '2' }) // @ts-expect-error Operator routes belong to coordinatorctl, not the client. client.doctor('header.payload.signature') diff --git a/crates/registry-coordinator-client-node/client.d.ts b/crates/registry-coordinator-client-node/client.d.ts index 9bb713ec84..43f86e633d 100644 --- a/crates/registry-coordinator-client-node/client.d.ts +++ b/crates/registry-coordinator-client-node/client.d.ts @@ -9,18 +9,19 @@ export type JsonValue = JsonScalar | ReadonlyArray | { readonly [key: export interface CoordinatorClientConfig { baseUrl: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + /** Additional trust roots for the deployment, as PEM text. */ + trustedRootCertificates?: string /** * How many times an admission whose outcome is unknown is resent, * identically and under the same idempotency key, before its error is * returned: a whole number from 0 to 2, default 2; 0 sends it once. Any * other value is a `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export interface StartRunRequest { workflow: string; workflowRevision: string; input: JsonValue } @@ -184,7 +185,7 @@ export class CoordinatorClient { * Admit `{workflow, workflowRevision, input}` under the caller's key. Fresh * starts must match the active revision. Recover unknown admissions with the * original revision even after activation changes. An unknown outcome is resent with - * the same key and body at most `maxMutationRetries` times; a 4xx is never + * the same key and body at most `maximumMutationRetries` times; a 4xx is never * resent. The key is scoped to the caller and the workflow, so the same key * under two workflows starts two runs, and an allowed workflow the deployment does * not serve is refused with 404 `workflow.not-served`, a known outcome never resent. diff --git a/crates/registry-coordinator-client-node/src/lib.rs b/crates/registry-coordinator-client-node/src/lib.rs index 01f7ec64e4..963438c76f 100644 --- a/crates/registry-coordinator-client-node/src/lib.rs +++ b/crates/registry-coordinator-client-node/src/lib.rs @@ -25,12 +25,12 @@ const MAXIMUM_JAVASCRIPT_SAFE_INTEGER: i64 = 9_007_199_254_740_991; #[serde(rename_all = "camelCase", deny_unknown_fields)] struct CoordinatorClientConfig { base_url: String, - request_timeout_milliseconds: Option, + attempt_timeout_milliseconds: Option, connect_timeout_milliseconds: Option, - max_response_bytes: Option, + maximum_response_bytes: Option, user_agent: Option, trusted_root_certificates: Option, - max_mutation_retries: Option, + maximum_mutation_retries: Option, } #[napi(object)] @@ -53,6 +53,7 @@ impl CoordinatorClient { ts_args_type = "config: import('./client').CoordinatorClientConfig" )] pub fn new(config: Value) -> Result { + reject_null_configuration_members(&config)?; let config: CoordinatorClientConfig = serde_json::from_value(config).map_err(|_| { binding_error( "configuration", @@ -66,14 +67,14 @@ impl CoordinatorClient { ) })?; let mut core = CoreConfig::new(base_url); - if let Some(value) = config.request_timeout_milliseconds { - core = core.with_request_timeout(Duration::from_millis(whole_number(value)?)); + if let Some(value) = config.attempt_timeout_milliseconds { + core = core.with_attempt_timeout(Duration::from_millis(whole_number(value)?)); } if let Some(value) = config.connect_timeout_milliseconds { core = core.with_connect_timeout(Duration::from_millis(whole_number(value)?)); } - if let Some(value) = config.max_response_bytes { - core = core.with_max_response_bytes(whole_number(value)?); + if let Some(value) = config.maximum_response_bytes { + core = core.with_maximum_response_bytes(whole_number(value)?); } if let Some(value) = config.user_agent { core = core.with_user_agent(value); @@ -81,8 +82,8 @@ impl CoordinatorClient { if let Some(value) = config.trusted_root_certificates { core = core.with_trusted_root_certificates(value.into_bytes()); } - if let Some(value) = config.max_mutation_retries { - core = core.with_max_mutation_retries(mutation_retries(value)?); + if let Some(value) = config.maximum_mutation_retries { + core = core.with_maximum_mutation_retries(mutation_retries(value)?); } CoreClient::new(core) .map(|inner| Self { inner }) @@ -338,6 +339,21 @@ fn binding_error(kind: &'static str, message: &'static str) -> NapiError { ) } +/// JavaScript `null` is a value, not omission. Optional configuration is +/// omitted by leaving the member absent. +fn reject_null_configuration_members(config: &Value) -> Result<()> { + if config + .as_object() + .is_some_and(|members| members.values().any(Value::is_null)) + { + return Err(binding_error( + "configuration", + "Coordinator client configuration is invalid", + )); + } + Ok(()) +} + /// The retry ceiling as a JavaScript number. Only a whole number in the /// `u8` range reaches the client, which refuses one above its own bound. fn mutation_retries(value: f64) -> Result { diff --git a/crates/registry-coordinator-client-py/README.md b/crates/registry-coordinator-client-py/README.md index dd66945092..2520e05e4e 100644 --- a/crates/registry-coordinator-client-py/README.md +++ b/crates/registry-coordinator-client-py/README.md @@ -60,9 +60,9 @@ canonical Rust client's camel-case wire DTOs inside a `{"kind": "complete", "val Complete outcome envelopes have no trace field; problem validation still binds the response trace. Configuration members are keyword arguments: `base_url` (required), -`request_timeout_seconds`, `connect_timeout_seconds`, -`max_response_bytes`, `user_agent`, `trusted_root_certificates`, and -`max_mutation_retries`. Timeouts are seconds, as a finite non-negative `int` or `float`; the response +`attempt_timeout_seconds`, `connect_timeout_seconds`, +`maximum_response_bytes`, `user_agent`, `trusted_root_certificates`, and +`maximum_mutation_retries`. Timeouts are seconds, as a finite positive `int` or `float`; the response bound is an `int`. A `bool` is not a number here. An unknown keyword is a Python `TypeError`. `CoordinatorClientError` preserves the status and the `family.reason` code of @@ -77,7 +77,7 @@ the answer; anything else is a `protocol` failure. Its `kind` is one of `configu The only resend is the Rust client's bounded same-key retry of `start`. An admission whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same key up -to `max_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. A 4xx +to `maximum_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. A 4xx answer is never resent. `workflow`, `list`, `status`, `inspect`, `reconcile`, `retry_same`, and `cancel` send exactly one request; `retry_same` and `cancel` take no idempotency key, so after an unknown outcome read the run before diff --git a/crates/registry-coordinator-client-py/python/registry_coordinator_client/__init__.pyi b/crates/registry-coordinator-client-py/python/registry_coordinator_client/__init__.pyi index 847f6d9d28..9d8bc434e7 100644 --- a/crates/registry-coordinator-client-py/python/registry_coordinator_client/__init__.pyi +++ b/crates/registry-coordinator-client-py/python/registry_coordinator_client/__init__.pyi @@ -179,9 +179,9 @@ class CoordinatorClientError(Exception): outcome_unknown: bool class CoordinatorClient: - def __init__(self, base_url: str, request_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, max_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, max_mutation_retries: int | None = None) -> None: ... + def __init__(self, base_url: str, attempt_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, maximum_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, maximum_mutation_retries: int | None = None) -> None: ... # Admit `{workflow, workflowRevision, input}` under the caller's key; resent under the same key - # up to `max_mutation_retries` times when the outcome is unknown. The key + # up to `maximum_mutation_retries` times when the outcome is unknown. The key # is scoped to the caller and the workflow, so the same key under two workflows # starts two runs, and an allowed workflow the deployment does not serve is # refused with 404 `workflow.not-served`, a known outcome never resent. diff --git a/crates/registry-coordinator-client-py/src/lib.rs b/crates/registry-coordinator-client-py/src/lib.rs index f64cca57eb..0a5f5056bb 100644 --- a/crates/registry-coordinator-client-py/src/lib.rs +++ b/crates/registry-coordinator-client-py/src/lib.rs @@ -261,28 +261,28 @@ struct CoordinatorClient { #[pymethods] impl CoordinatorClient { #[new] - #[pyo3(signature = (base_url, request_timeout_seconds=None, connect_timeout_seconds=None, max_response_bytes=None, user_agent=None, trusted_root_certificates=None, max_mutation_retries=None))] + #[pyo3(signature = (base_url, attempt_timeout_seconds=None, connect_timeout_seconds=None, maximum_response_bytes=None, user_agent=None, trusted_root_certificates=None, maximum_mutation_retries=None))] #[allow(clippy::too_many_arguments)] fn new( py: Python<'_>, base_url: &str, - request_timeout_seconds: Option<&Bound<'_, PyAny>>, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, connect_timeout_seconds: Option<&Bound<'_, PyAny>>, - max_response_bytes: Option<&Bound<'_, PyAny>>, + maximum_response_bytes: Option<&Bound<'_, PyAny>>, user_agent: Option, trusted_root_certificates: Option>, - max_mutation_retries: Option<&Bound<'_, PyAny>>, + maximum_mutation_retries: Option<&Bound<'_, PyAny>>, ) -> PyResult { let base_url = Url::parse(base_url).map_err(|_| invalid_configuration(py))?; let mut config = CoordinatorClientConfig::new(base_url); - if let Some(value) = request_timeout_seconds { - config = config.with_request_timeout(duration(py, value)?); + if let Some(value) = attempt_timeout_seconds { + config = config.with_attempt_timeout(duration(py, value)?); } if let Some(value) = connect_timeout_seconds { config = config.with_connect_timeout(duration(py, value)?); } - if let Some(value) = max_response_bytes { - config = config.with_max_response_bytes(response_bytes(py, value)?); + if let Some(value) = maximum_response_bytes { + config = config.with_maximum_response_bytes(response_bytes(py, value)?); } if let Some(value) = user_agent { config = config.with_user_agent(value); @@ -290,8 +290,8 @@ impl CoordinatorClient { if let Some(value) = trusted_root_certificates { config = config.with_trusted_root_certificates(value); } - if let Some(value) = max_mutation_retries { - config = config.with_max_mutation_retries(mutation_retries(py, value)?); + if let Some(value) = maximum_mutation_retries { + config = config.with_maximum_mutation_retries(mutation_retries(py, value)?); } let inner = py .detach(|| RustClient::new(config)) diff --git a/crates/registry-coordinator-client-py/tests/python/test_construction.py b/crates/registry-coordinator-client-py/tests/python/test_construction.py index f6decbe58c..3b90b50639 100644 --- a/crates/registry-coordinator-client-py/tests/python/test_construction.py +++ b/crates/registry-coordinator-client-py/tests/python/test_construction.py @@ -35,28 +35,28 @@ def test_an_unusable_base_url_is_a_configuration_error(self) -> None: def test_unknown_or_missing_members_are_refused(self) -> None: with self.assertRaises(TypeError): - CoordinatorClient(BASE, request_timeout_milliseconds=1) # type: ignore[call-arg] + CoordinatorClient(BASE, attempt_timeout_milliseconds=1) # type: ignore[call-arg] with self.assertRaises(TypeError): CoordinatorClient() # type: ignore[call-arg] def test_none_is_the_default_for_every_optional_member(self) -> None: CoordinatorClient( BASE, - request_timeout_seconds=None, + attempt_timeout_seconds=None, connect_timeout_seconds=None, - max_response_bytes=None, + maximum_response_bytes=None, user_agent=None, trusted_root_certificates=None, - max_mutation_retries=None, + maximum_mutation_retries=None, ) def test_members_accept_values_in_range(self) -> None: for keyword, value in ( - ("request_timeout_seconds", 2), - ("request_timeout_seconds", 1.5), + ("attempt_timeout_seconds", 2), + ("attempt_timeout_seconds", 1.5), ("connect_timeout_seconds", 2), ("connect_timeout_seconds", 1.5), - ("max_response_bytes", 1500), + ("maximum_response_bytes", 1500), ("user_agent", "adopter/1"), ): with self.subTest(keyword=keyword): @@ -64,23 +64,23 @@ def test_members_accept_values_in_range(self) -> None: def test_bool_or_out_of_range_members_are_configuration_errors(self) -> None: for keyword, value in ( - ("request_timeout_seconds", True), - ("request_timeout_seconds", False), - ("request_timeout_seconds", 0), - ("request_timeout_seconds", -1), - ("request_timeout_seconds", float("nan")), - ("request_timeout_seconds", float("inf")), - ("request_timeout_seconds", 1e300), - ("request_timeout_seconds", "2"), + ("attempt_timeout_seconds", True), + ("attempt_timeout_seconds", False), + ("attempt_timeout_seconds", 0), + ("attempt_timeout_seconds", -1), + ("attempt_timeout_seconds", float("nan")), + ("attempt_timeout_seconds", float("inf")), + ("attempt_timeout_seconds", 1e300), + ("attempt_timeout_seconds", "2"), ("connect_timeout_seconds", True), ("connect_timeout_seconds", -1), ("connect_timeout_seconds", 0), - ("max_response_bytes", True), - ("max_response_bytes", 0), - ("max_response_bytes", -1), - ("max_response_bytes", 2**64), - ("max_response_bytes", 16 * 1024 * 1024 + 1), - ("max_response_bytes", "1500"), + ("maximum_response_bytes", True), + ("maximum_response_bytes", 0), + ("maximum_response_bytes", -1), + ("maximum_response_bytes", 2**64), + ("maximum_response_bytes", 16 * 1024 * 1024 + 1), + ("maximum_response_bytes", "1500"), ): with self.subTest(keyword=keyword, value=value): with self.assertRaises(CoordinatorClientError) as raised: @@ -91,11 +91,11 @@ def test_bool_or_out_of_range_members_are_configuration_errors(self) -> None: def test_mutation_retries_accept_zero_to_two_only(self) -> None: for retries in (0, 1, 2): with self.subTest(retries=retries): - CoordinatorClient(BASE, max_mutation_retries=retries) + CoordinatorClient(BASE, maximum_mutation_retries=retries) for retries in (3, 255, 256, -1, 1.5, "1", True): with self.subTest(retries=retries): with self.assertRaises(CoordinatorClientError) as raised: - CoordinatorClient(BASE, max_mutation_retries=retries) + CoordinatorClient(BASE, maximum_mutation_retries=retries) self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) diff --git a/crates/registry-coordinator-client-py/tests/python/test_mutation_retry.py b/crates/registry-coordinator-client-py/tests/python/test_mutation_retry.py index baf4a738cc..65a84d4d30 100644 --- a/crates/registry-coordinator-client-py/tests/python/test_mutation_retry.py +++ b/crates/registry-coordinator-client-py/tests/python/test_mutation_retry.py @@ -49,7 +49,7 @@ def test_the_default_ceiling_is_two_resends(self) -> None: def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> None: self.service.script(UNAVAILABLE, ok(STATUS)) - client = CoordinatorClient(self.service.base_url, max_mutation_retries=0) + client = CoordinatorClient(self.service.base_url, maximum_mutation_retries=0) with self.assertRaises(CoordinatorClientError) as raised: client.start("one-call-token", KEY, START_REQUEST) @@ -62,7 +62,7 @@ def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> N def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> None: self.service.script(KEY_REUSED, ok(STATUS)) - client = CoordinatorClient(self.service.base_url, max_mutation_retries=2) + client = CoordinatorClient(self.service.base_url, maximum_mutation_retries=2) with self.assertRaises(CoordinatorClientError) as raised: client.start("one-call-token", KEY, START_REQUEST) @@ -76,7 +76,7 @@ def test_a_flow_the_deployment_does_not_serve_is_never_resent_and_reports_the_ou self, ) -> None: self.service.script(problem(404, "workflow.not-served"), ok(STATUS)) - client = CoordinatorClient(self.service.base_url, max_mutation_retries=2) + client = CoordinatorClient(self.service.base_url, maximum_mutation_retries=2) with self.assertRaises(CoordinatorClientError) as raised: client.start("one-call-token", KEY, START_REQUEST) @@ -101,7 +101,7 @@ def test_a_run_of_a_flow_outside_the_caller_policy_reads_as_absent_on_every_run_ with self.subTest(path=path): self.service.script(problem(404, "run.not-visible"), ok(INSPECTION)) with self.assertRaises(CoordinatorClientError) as raised: - call(CoordinatorClient(self.service.base_url, max_mutation_retries=2)) + call(CoordinatorClient(self.service.base_url, maximum_mutation_retries=2)) self.assertEqual(raised.exception.kind, "problem") self.assertEqual(raised.exception.status, 404) self.assertEqual(raised.exception.code, "run.not-visible") @@ -120,7 +120,7 @@ def test_every_call_but_start_is_never_resent(self) -> None: with self.subTest(path=path): self.service.script(UNAVAILABLE, ok(INSPECTION)) with self.assertRaises(CoordinatorClientError) as raised: - call(CoordinatorClient(self.service.base_url, max_mutation_retries=2)) + call(CoordinatorClient(self.service.base_url, maximum_mutation_retries=2)) self.assertEqual(raised.exception.status, 503) self.assertIs(raised.exception.outcome_unknown, True) self.assertEqual([o["path"] for o in self.service.observations()], [path]) diff --git a/crates/registry-coordinator-client-py/tests/python/test_native_requests.py b/crates/registry-coordinator-client-py/tests/python/test_native_requests.py index 533705af7d..55e09a71a0 100644 --- a/crates/registry-coordinator-client-py/tests/python/test_native_requests.py +++ b/crates/registry-coordinator-client-py/tests/python/test_native_requests.py @@ -583,7 +583,7 @@ def test_a_redirect_is_not_followed(self) -> None: def test_an_oversized_answer_is_a_transport_failure(self) -> None: self.service.script(ok(STATUS)) with self.assertRaises(CoordinatorClientError) as raised: - CoordinatorClient(self.service.base_url, max_response_bytes=16).status("token", RUN) + CoordinatorClient(self.service.base_url, maximum_response_bytes=16).status("token", RUN) self.assertEqual(raised.exception.kind, "transport") self.assertIsInstance(raised.exception.transport_kind, str) diff --git a/crates/registry-coordinator-client/README.md b/crates/registry-coordinator-client/README.md index 65512719ff..8b3eba7b25 100644 --- a/crates/registry-coordinator-client/README.md +++ b/crates/registry-coordinator-client/README.md @@ -21,7 +21,7 @@ the deployment does not serve is refused with 404 `workflow.not-served`, a known outcome the client never resends. When an admission's outcome is unknown, after a timeout or broken exchange or on a 5xx answer, the client resends the identical request under the same key, at most -`with_max_mutation_retries` times (2 by default, at most 2, 0 disables it). +`with_maximum_mutation_retries` times (2 by default, at most 2, 0 disables it). It never resends after a 4xx status line. An error that still leaves the outcome unknown may have admitted a run: the caller retains the exact key, workflow, revision, input, and service identity in durable state before dispatch and diff --git a/crates/registry-coordinator-client/src/client.rs b/crates/registry-coordinator-client/src/client.rs index d84632a879..49245e8b67 100644 --- a/crates/registry-coordinator-client/src/client.rs +++ b/crates/registry-coordinator-client/src/client.rs @@ -39,16 +39,16 @@ pub struct CoordinatorComplete { pub struct CoordinatorClient { http: reqwest::Client, base_url: ServiceBaseUrl, - max_response_bytes: u64, - max_mutation_retries: u8, + maximum_response_bytes: u64, + maximum_mutation_retries: u8, } impl fmt::Debug for CoordinatorClient { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("CoordinatorClient") .field("base_url", &"") - .field("max_response_bytes", &self.max_response_bytes) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_response_bytes", &self.maximum_response_bytes) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -57,7 +57,7 @@ impl CoordinatorClient { pub fn new(config: CoordinatorClientConfig) -> Result { let base_url = config.validate()?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config.trusted_root_certificates.as_deref(), @@ -66,8 +66,8 @@ impl CoordinatorClient { Ok(Self { http, base_url, - max_response_bytes: config.max_response_bytes, - max_mutation_retries: config.max_mutation_retries, + maximum_response_bytes: config.maximum_response_bytes, + maximum_mutation_retries: config.maximum_mutation_retries, }) } @@ -79,7 +79,7 @@ impl CoordinatorClient { /// The caller chooses the key; the client never invents one. When the /// outcome is unknown, after a timeout or broken exchange or on a 5xx /// answer, the client resends the identical request under the same key, - /// at most `CoordinatorClientConfig::with_max_mutation_retries` times (2 + /// at most `CoordinatorClientConfig::with_maximum_mutation_retries` times (2 /// by default; 0 disables it). An error for which /// `CoordinatorClientError::is_outcome_unknown` is true may still have /// admitted a run: recover with the same identity, key, workflow, revision and @@ -110,7 +110,7 @@ impl CoordinatorClient { let prepared = &prepared; let workflow = request.workflow.as_str(); let revision = request.workflow_revision.as_str(); - let value = retry_keyed_mutation(self.max_mutation_retries, || async move { + let value = retry_keyed_mutation(self.maximum_mutation_retries, || async move { let Some(attempt) = prepared.try_clone() else { return KeyedMutationAttempt::Settled(Err(Error::invalid_request( "the request could not be prepared for sending", @@ -394,7 +394,7 @@ impl CoordinatorClient { if !exact_media_type(response.headers(), JSON_MEDIA_TYPE) { return Err(protocol(status.as_u16(), Failure::MediaType)); } - let bytes = read_bounded(response, self.max_response_bytes) + let bytes = read_bounded(response, self.maximum_response_bytes) .await .map_err(|e| Error::Transport { kind: read_failure_kind(&e), @@ -422,7 +422,7 @@ impl CoordinatorClient { return protocol(status.as_u16(), Failure::MediaType); } let trace = response_trace_id(response.headers()).ok(); - let bound = self.max_response_bytes.min(MAXIMUM_PROBLEM_BYTES); + let bound = self.maximum_response_bytes.min(MAXIMUM_PROBLEM_BYTES); let bytes = match read_bounded(response, bound).await { Ok(bytes) => bytes, Err(error) => { diff --git a/crates/registry-coordinator-client/src/config.rs b/crates/registry-coordinator-client/src/config.rs index bd52939290..25e0bf2125 100644 --- a/crates/registry-coordinator-client/src/config.rs +++ b/crates/registry-coordinator-client/src/config.rs @@ -14,12 +14,12 @@ use crate::CoordinatorClientError; #[derive(Clone)] pub struct CoordinatorClientConfig { pub(crate) base_url: Url, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>, - pub(crate) max_mutation_retries: u8, + pub(crate) maximum_mutation_retries: u8, } impl CoordinatorClientConfig { @@ -27,17 +27,17 @@ impl CoordinatorClientConfig { pub fn new(base_url: Url) -> Self { Self { base_url, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_REQUEST_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, - max_response_bytes: 4 * 1024 * 1024, + maximum_response_bytes: 4 * 1024 * 1024, user_agent: None, trusted_root_certificates: None, - max_mutation_retries: DEFAULT_MUTATION_RETRIES, + maximum_mutation_retries: DEFAULT_MUTATION_RETRIES, } } #[must_use] - pub fn with_request_timeout(mut self, value: Duration) -> Self { - self.request_timeout = value; + pub fn with_attempt_timeout(mut self, value: Duration) -> Self { + self.attempt_timeout = value; self } #[must_use] @@ -46,8 +46,8 @@ impl CoordinatorClientConfig { self } #[must_use] - pub fn with_max_response_bytes(mut self, value: u64) -> Self { - self.max_response_bytes = value; + pub fn with_maximum_response_bytes(mut self, value: u64) -> Self { + self.maximum_response_bytes = value; self } #[must_use] @@ -64,20 +64,20 @@ impl CoordinatorClientConfig { /// identically and under the same idempotency key, before its error is /// returned. Defaults to 2, accepts at most 2, and 0 disables the resend. #[must_use] - pub fn with_max_mutation_retries(mut self, value: u8) -> Self { - self.max_mutation_retries = value; + pub fn with_maximum_mutation_retries(mut self, value: u8) -> Self { + self.maximum_mutation_retries = value; self } pub(crate) fn validate(&self) -> Result { let base = ServiceBaseUrl::new(self.base_url.clone()).map_err(|_| { CoordinatorClientError::configuration("the service base URL is not usable") })?; - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(CoordinatorClientError::configuration( "client timeouts must be greater than zero", )); } - if self.max_response_bytes == 0 || self.max_response_bytes > 16 * 1024 * 1024 { + if self.maximum_response_bytes == 0 || self.maximum_response_bytes > 16 * 1024 * 1024 { return Err(CoordinatorClientError::configuration( "the response body bound is outside the accepted range", )); @@ -91,7 +91,7 @@ impl CoordinatorClientConfig { "the certificate bundle exceeds the accepted bound", )); } - if self.max_mutation_retries > MAXIMUM_MUTATION_RETRIES { + if self.maximum_mutation_retries > MAXIMUM_MUTATION_RETRIES { return Err(CoordinatorClientError::configuration( "the mutation retry count exceeds the accepted bound", )); @@ -104,15 +104,15 @@ impl fmt::Debug for CoordinatorClientConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("CoordinatorClientConfig") .field("base_url", &"") - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("user_agent_present", &self.user_agent.is_some()) .field( "trusted_root_certificates_present", &self.trusted_root_certificates.is_some(), ) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish() } } diff --git a/crates/registry-coordinator-client/src/lib.rs b/crates/registry-coordinator-client/src/lib.rs index e0287ced15..61360c3ab2 100644 --- a/crates/registry-coordinator-client/src/lib.rs +++ b/crates/registry-coordinator-client/src/lib.rs @@ -5,7 +5,7 @@ //! exactly one request; `retry_same` and `cancel` take no idempotency key, so //! the client never resends them. `start` resends an admission whose outcome //! is unknown under the caller's same key and body, at most -//! `CoordinatorClientConfig::with_max_mutation_retries` times. The caller +//! `CoordinatorClientConfig::with_maximum_mutation_retries` times. The caller //! chooses the key and, after an error whose outcome is unknown, must retain //! the same verified issuer, subject, client, workflow, input and key to recover. //! A service-owned run never becomes citizen-owned because its input names a diff --git a/crates/registry-coordinator-client/tests/http.rs b/crates/registry-coordinator-client/tests/http.rs index a52b18e364..c5e0661264 100644 --- a/crates/registry-coordinator-client/tests/http.rs +++ b/crates/registry-coordinator-client/tests/http.rs @@ -125,7 +125,7 @@ impl Server { } fn client_with_retries(&self, retries: u8) -> CoordinatorClient { CoordinatorClient::new( - CoordinatorClientConfig::new(self.url.clone()).with_max_mutation_retries(retries), + CoordinatorClientConfig::new(self.url.clone()).with_maximum_mutation_retries(retries), ) .unwrap() } @@ -1212,8 +1212,8 @@ async fn a_lost_cancellation_reply_is_sent_once_and_left_unknown() { let server = Server::with_wait(status(), Duration::from_millis(250)).await; let client = CoordinatorClient::new( CoordinatorClientConfig::new(server.url.clone()) - .with_request_timeout(Duration::from_millis(70)) - .with_max_mutation_retries(2), + .with_attempt_timeout(Duration::from_millis(70)) + .with_maximum_mutation_retries(2), ) .unwrap(); let run = Uuid::parse_str(RUN).unwrap(); @@ -1575,7 +1575,7 @@ async fn malformed_ambiguous_redirected_or_wrong_run_answers_are_refused() { assert_eq!(server.count(), sent, "a redirect was never followed"); server.answer(200, "application/json", &status().to_string()); let bounded = CoordinatorClient::new( - CoordinatorClientConfig::new(server.url.clone()).with_max_response_bytes(64), + CoordinatorClientConfig::new(server.url.clone()).with_maximum_response_bytes(64), ) .unwrap(); assert!(matches!( @@ -1591,8 +1591,8 @@ async fn a_lost_admission_reply_is_unknown_and_resent_under_the_original_key() { let server = Server::with_wait(status(), Duration::from_millis(250)).await; let client = CoordinatorClient::new( CoordinatorClientConfig::new(server.url.clone()) - .with_request_timeout(Duration::from_millis(70)) - .with_max_mutation_retries(1), + .with_attempt_timeout(Duration::from_millis(70)) + .with_maximum_mutation_retries(1), ) .unwrap(); let error = client @@ -1629,22 +1629,22 @@ fn config_bounds_and_debug_do_not_expose_configured_values() { } let url = Url::parse("https://example.test").unwrap(); assert!(CoordinatorClient::new( - CoordinatorClientConfig::new(url.clone()).with_request_timeout(Duration::ZERO) + CoordinatorClientConfig::new(url.clone()).with_attempt_timeout(Duration::ZERO) ) .is_err()); assert!(CoordinatorClient::new( - CoordinatorClientConfig::new(url.clone()).with_max_response_bytes(0) + CoordinatorClientConfig::new(url.clone()).with_maximum_response_bytes(0) ) .is_err()); assert!(matches!( CoordinatorClient::new( - CoordinatorClientConfig::new(url.clone()).with_max_mutation_retries(3) + CoordinatorClientConfig::new(url.clone()).with_maximum_mutation_retries(3) ), Err(CoordinatorClientError::Configuration { .. }) )); for retries in 0..=2 { assert!(CoordinatorClient::new( - CoordinatorClientConfig::new(url.clone()).with_max_mutation_retries(retries) + CoordinatorClientConfig::new(url.clone()).with_maximum_mutation_retries(retries) ) .is_ok()); } @@ -1883,7 +1883,7 @@ async fn admission_revision_is_required_before_io_and_bound_to_the_answer() { other["workflowRevision"] = json!(format!("sha256:{}", "1".repeat(64))); server.answer(200, "application/json", &other.to_string()); let client = CoordinatorClient::new( - CoordinatorClientConfig::new(server.url.clone()).with_max_mutation_retries(0), + CoordinatorClientConfig::new(server.url.clone()).with_maximum_mutation_retries(0), ) .unwrap(); assert!(matches!( diff --git a/crates/registry-coordinator/Cargo.toml b/crates/registry-coordinator/Cargo.toml index e527b130e0..935aaef317 100644 --- a/crates/registry-coordinator/Cargo.toml +++ b/crates/registry-coordinator/Cargo.toml @@ -134,6 +134,7 @@ registry-review-protocol.workspace = true registry-scheduling-client.workspace = true registry-scheduling-core.workspace = true reqwest.workspace = true +rustix.workspace = true rustls.workspace = true rhai.workspace = true schemars = { workspace = true, optional = true, features = ["chrono04", "uuid1"] } diff --git a/crates/registry-coordinator/src/adapters.rs b/crates/registry-coordinator/src/adapters.rs index eeec8afa42..accc786ee6 100644 --- a/crates/registry-coordinator/src/adapters.rs +++ b/crates/registry-coordinator/src/adapters.rs @@ -113,7 +113,7 @@ fn tokens( .with_resource(resource) .with_scopes(scopes.iter().cloned()) .with_fetch_url_policy(configured_endpoint_policy(&auth.token_endpoint)) - .with_request_timeout(REQUEST_TIMEOUT) + .with_attempt_timeout(REQUEST_TIMEOUT) .with_connect_timeout(Duration::from_millis( crate::operations::HTTP_CONNECT_TIMEOUT_MILLISECONDS, )); @@ -130,12 +130,12 @@ fn breg_client( BaseRegistryClient::new( BaseRegistryClientConfig::new(url) .with_token_provider(tokens) - .with_max_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) - .with_request_timeout(REQUEST_TIMEOUT) + .with_maximum_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) + .with_attempt_timeout(REQUEST_TIMEOUT) .with_connect_timeout(Duration::from_millis( crate::operations::HTTP_CONNECT_TIMEOUT_MILLISECONDS, )) - .with_max_response_bytes(FILE_BOUND), + .with_maximum_response_bytes(FILE_BOUND), ) } @@ -247,12 +247,12 @@ impl HttpAdapters { }; let client = SchedulingClient::new( SchedulingClientConfig::new(binding.base_url.clone()) - .with_max_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) - .with_request_timeout(REQUEST_TIMEOUT) + .with_maximum_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) + .with_attempt_timeout(REQUEST_TIMEOUT) .with_connect_timeout(Duration::from_millis( crate::operations::HTTP_CONNECT_TIMEOUT_MILLISECONDS, )) - .with_max_response_bytes(FILE_BOUND), + .with_maximum_response_bytes(FILE_BOUND), ) .map_err(|_| config_error())?; Connection::Scheduling(Box::new(SchedulingConnection { @@ -270,12 +270,12 @@ impl HttpAdapters { ); let client = MessagingClient::new( MessagingClientConfig::new(binding.base_url.clone()) - .with_max_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) - .with_request_timeout(REQUEST_TIMEOUT) + .with_maximum_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) + .with_attempt_timeout(REQUEST_TIMEOUT) .with_connect_timeout(Duration::from_millis( crate::operations::HTTP_CONNECT_TIMEOUT_MILLISECONDS, )) - .with_max_response_bytes(FILE_BOUND), + .with_maximum_response_bytes(FILE_BOUND), ) .map_err(|_| config_error())?; Connection::Messaging { client, tokens } @@ -306,12 +306,12 @@ impl HttpAdapters { .map_err(|_| config_error())?; let client = CaseworkClient::new( CaseworkClientConfig::new(binding.base_url.clone()) - .with_max_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) - .with_request_timeout(REQUEST_TIMEOUT) + .with_maximum_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) + .with_attempt_timeout(REQUEST_TIMEOUT) .with_connect_timeout(Duration::from_millis( crate::operations::HTTP_CONNECT_TIMEOUT_MILLISECONDS, )) - .with_max_response_bytes(FILE_BOUND), + .with_maximum_response_bytes(FILE_BOUND), ) .map_err(|_| config_error())?; Connection::Casework(Box::new(crate::review::CaseworkConnection::new( @@ -422,12 +422,12 @@ fn task_provider( .map_err(|error| token_failure(&error))?; let casework = CaseworkClient::new( CaseworkClientConfig::new(authority.base_url.clone()) - .with_max_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) - .with_request_timeout(REQUEST_TIMEOUT) + .with_maximum_mutation_retries(crate::operations::HTTP_MUTATION_RETRIES) + .with_attempt_timeout(REQUEST_TIMEOUT) .with_connect_timeout(Duration::from_millis( crate::operations::HTTP_CONNECT_TIMEOUT_MILLISECONDS, )) - .with_max_response_bytes(FILE_BOUND), + .with_maximum_response_bytes(FILE_BOUND), ) .map_err(|_| refused("credential-configuration"))?; let source = CaseworkTaskAssertionSource::new( diff --git a/crates/registry-coordinator/src/cli.rs b/crates/registry-coordinator/src/cli.rs index a29dc98267..14b0e7fa5e 100644 --- a/crates/registry-coordinator/src/cli.rs +++ b/crates/registry-coordinator/src/cli.rs @@ -7,11 +7,14 @@ use crate::{ use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use serde_json::{json, Value}; use std::{ + fs::File, io::Read, path::{Path, PathBuf}, time::Duration, }; use uuid::Uuid; + +mod selected_binary; pub const CTL_REPORT_API_VERSION: &str = "id.registrystack.org/formats/coordinator/ctl-report/v1alpha1"; pub const CTL_REPORT_KIND: &str = "CoordinatorCtlReport"; @@ -37,7 +40,7 @@ struct Service { /// Authenticated Coordinator service origin, HTTPS or explicit loopback HTTP. #[arg(long)] url: Option, - /// File containing a current access token; a relative path is read from the current directory. Token values are never printed. + /// Regular file containing a current access token; symlinks and special files are refused. A relative path is read from the current directory. Token values are never printed. #[arg(long, value_name = "FILE")] token_file: Option, } @@ -78,6 +81,12 @@ impl ListState { } #[derive(Subcommand)] enum Command { + /// Run the production Coordinator service with the selected runtime. + Serve { + /// Coordinator runtime executable for an unmanaged installation. + #[arg(long, hide = true, env = "COORDINATOR_BIN", value_name = "BINARY")] + coordinator_bin: Option, + }, /// Create a reviewed four-file starter and its .gitignore in a new or empty directory. Init { #[arg(long)] @@ -309,6 +318,7 @@ enum Command { impl Command { fn name(&self) -> &'static str { match self { + Self::Serve { .. } => "serve", Self::Init { .. } => "init", Self::Check { .. } => "check", Self::Explain { .. } => "explain", @@ -435,13 +445,15 @@ fn test(project: &Path, scenarios: Option<&Path>) -> Result { } /// Read the file `argument` names, up to `maximum` bytes. fn read(path: &Path, maximum: usize, argument: &str) -> Result> { + let file = File::open(path).map_err(|_| unreadable_file(argument))?; + read_open_file(file, maximum, argument) +} + +fn read_open_file(file: File, maximum: usize, argument: &str) -> Result> { let mut bytes = Vec::new(); - std::fs::File::open(path) - .and_then(|f| f.take(maximum as u64 + 1).read_to_end(&mut bytes)) - .map_err(|_| { - CoordinatorError::new("input-invalid", format!("cannot read the {argument} file")) - .suggest(format!("Name a readable regular file with {argument}.")) - })?; + file.take(maximum as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|_| unreadable_file(argument))?; if bytes.len() > maximum { return Err(CoordinatorError::new( "input-invalid", @@ -451,6 +463,50 @@ fn read(path: &Path, maximum: usize, argument: &str) -> Result> { } Ok(bytes) } + +fn unreadable_file(argument: &str) -> CoordinatorError { + CoordinatorError::new("input-invalid", format!("cannot read the {argument} file")) + .suggest(format!("Name a readable regular file with {argument}.")) +} + +#[cfg(unix)] +fn read_token_file(path: &Path, maximum: usize) -> Result> { + use rustix::fs::{Mode, OFlags}; + + let descriptor = rustix::fs::open( + path, + OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK, + Mode::empty(), + ) + .map_err(|_| unreadable_file("--token-file"))?; + let file = File::from(descriptor); + if !file + .metadata() + .map_err(|_| unreadable_file("--token-file"))? + .is_file() + { + return Err(unreadable_file("--token-file")); + } + read_open_file(file, maximum, "--token-file") +} + +#[cfg(not(unix))] +fn read_token_file(path: &Path, maximum: usize) -> Result> { + let metadata = std::fs::symlink_metadata(path).map_err(|_| unreadable_file("--token-file"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(unreadable_file("--token-file")); + } + let file = File::open(path).map_err(|_| unreadable_file("--token-file"))?; + if !file + .metadata() + .map_err(|_| unreadable_file("--token-file"))? + .is_file() + { + return Err(unreadable_file("--token-file")); + } + read_open_file(file, maximum, "--token-file") +} + fn text_file(path: &Path, maximum: usize, argument: &str) -> Result { String::from_utf8(read(path, maximum, argument)?) .map(|s| s.trim_end_matches(['\r', '\n']).into()) @@ -462,6 +518,14 @@ fn text_file(path: &Path, maximum: usize, argument: &str) -> Result { .suggest(format!("Write the {argument} file as UTF-8 text.")) }) } +fn token_text_file(path: &Path, maximum: usize) -> Result { + String::from_utf8(read_token_file(path, maximum)?) + .map(|s| s.trim_end_matches(['\r', '\n']).into()) + .map_err(|_| { + CoordinatorError::new("input-invalid", "the --token-file file is not UTF-8 text") + .suggest("Write the --token-file file as UTF-8 text.") + }) +} fn runtime(cli: &Cli) -> Result { RuntimeConfig::load(&runtime_config(cli)?.ok_or_else(|| { CoordinatorError::new("runtime-config-required", "supply --runtime-config FILE") @@ -532,7 +596,7 @@ async fn remote( ) .suggest("Add --token-file with the path of a file that holds a current access token.") })?; - let token = zeroize::Zeroizing::new(text_file(token_path, 16_384, "--token-file")?); + let token = zeroize::Zeroizing::new(token_text_file(token_path, 16_384)?); registry_platform_authcommon::validate_compact_access_token(&token).map_err(|_| { CoordinatorError::new( "token-invalid", @@ -691,6 +755,9 @@ fn refusal_advice(problem: ProblemCode) -> Option<&'static str> { } async fn execute(cli: &Cli) -> Result { match &cli.command { + Command::Serve { .. } => { + unreachable!("serve replaces the ctl process before report dispatch") + } Command::Init { directory, id } => { let created = project::init(directory, id.as_deref())?; Ok(json!({"status":"initialized","project":directory,"created":created})) @@ -971,7 +1038,11 @@ fn refused_arguments(error: &clap::Error) -> Vec { .collect() } pub async fn run() -> std::process::ExitCode { - let cli = match Cli::try_parse() { + let os_arguments = std::env::args_os().collect::>(); + let format_requested = os_arguments.iter().any(|argument| { + argument == "--format" || argument.to_string_lossy().starts_with("--format=") + }); + let cli = match Cli::try_parse_from(&os_arguments) { Ok(cli) => cli, Err(error) if matches!( @@ -983,7 +1054,10 @@ pub async fn run() -> std::process::ExitCode { return std::process::ExitCode::SUCCESS; } Err(error) => { - let arguments = std::env::args().collect::>(); + let arguments = os_arguments + .iter() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(); let error = usage(&error, &arguments); let json = arguments .windows(2) @@ -997,6 +1071,9 @@ pub async fn run() -> std::process::ExitCode { return std::process::ExitCode::from(2); } }; + if let Command::Serve { coordinator_bin } = &cli.command { + return serve(&cli, coordinator_bin.as_deref(), format_requested); + } match execute(&cli).await { Ok(value) => { let tested = matches!(cli.command, Command::Test { .. }); @@ -1038,6 +1115,66 @@ pub async fn run() -> std::process::ExitCode { } } +fn serve(cli: &Cli, explicit: Option<&Path>, format_requested: bool) -> std::process::ExitCode { + if format_requested { + let error = CoordinatorError::new( + "coordinator.cli.usage", + "--format does not apply to serve because service output keeps the Coordinator runtime stream", + ) + .suggest("Remove --format and retry."); + match cli.format { + Format::Json => write_report(false, "usage", failure_value(&error, 2)), + Format::Human => eprint!("{}", error.report().render_human()), + } + return std::process::ExitCode::from(2); + } + let Some(runtime_config) = cli.runtime_config.as_ref() else { + let error = + CoordinatorError::new("coordinator.cli.usage", "serve requires --runtime-config") + .suggest("Add --runtime-config with the deployment's runtime file."); + eprint!("{}", error.report().render_human()); + return std::process::ExitCode::from(2); + }; + let binary = match selected_binary::resolve_managed(selected_binary::COORDINATOR, explicit) { + Ok(Some(binary)) => binary, + Ok(None) => explicit + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("coordinator")), + Err(error) => { + eprintln!("coordinatorctl serve could not select coordinator: {error}"); + return std::process::ExitCode::from(3); + } + }; + let mut command = std::process::Command::new(binary); + command.arg("--runtime-config").arg(runtime_config); + replace_with_service(command, "coordinator") +} + +#[cfg(unix)] +fn replace_with_service(mut command: std::process::Command, name: &str) -> std::process::ExitCode { + use std::os::unix::process::CommandExt as _; + + let error = command.exec(); + eprintln!("coordinatorctl serve could not start {name}: {error}"); + std::process::ExitCode::from(3) +} + +#[cfg(not(unix))] +fn replace_with_service(mut command: std::process::Command, name: &str) -> std::process::ExitCode { + match command.status() { + Ok(status) => std::process::ExitCode::from( + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(3), + ), + Err(error) => { + eprintln!("coordinatorctl serve could not start {name}: {error}"); + std::process::ExitCode::from(3) + } + } +} + /// The default output: a few lines a person reads. `--format json` keeps /// the full report, and the OpenAPI document stays JSON in both formats. fn render_human(cli: &Cli, value: &Value) -> String { diff --git a/crates/registry-coordinator/src/cli/selected_binary.rs b/crates/registry-coordinator/src/cli/selected_binary.rs new file mode 100644 index 0000000000..7893a56a09 --- /dev/null +++ b/crates/registry-coordinator/src/cli/selected_binary.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +type Result = std::result::Result; + +#[derive(Clone, Copy)] +pub(super) struct HelperSpec { + binary: &'static str, + component: &'static str, + explicit_source: &'static str, + managed_env: &'static str, +} + +pub(super) const COORDINATOR: HelperSpec = HelperSpec { + binary: "coordinator", + component: "coordinator", + explicit_source: "--coordinator-bin or COORDINATOR_BIN", + managed_env: "RSTACK_COORDINATOR_BIN", +}; + +pub(super) fn resolve_managed( + spec: HelperSpec, + explicit: Option<&Path>, +) -> Result> { + resolve_managed_with_environment( + spec, + explicit, + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(spec.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + ) +} + +fn resolve_managed_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result> { + let Some(generation_dir) = generation_dir else { + return Ok(None); + }; + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(Path::new(generation_dir)).map_err(|_| { + format!( + "rstack {release} selected generation {} cannot be resolved", + Path::new(generation_dir).display() + ) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let Some(managed_path) = managed_path else { + return missing_component(spec, &release); + }; + let selected = + canonical_helper(Path::new(managed_path)).or_else(|_| missing_component(spec, &release))?; + let layout = canonical_helper(&layout_path).or_else(|_| missing_component(spec, &release))?; + if selected != layout { + return Err(format!( + "rstack {release} provided {} outside the selected generation helper path at {}; run `rstack install {}` to repair the selected generation", + spec.managed_env, + layout_path.display(), + spec.component + )); + } + if let Some(override_path) = explicit { + let override_identity = canonical_helper(override_path).map_err(|_| { + format!( + "cannot resolve {} from {}", + spec.binary, spec.explicit_source + ) + })?; + if override_identity != selected { + return Err(format!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected generation identity", + spec.explicit_source, + override_path.display(), + spec.binary, + selected.display() + )); + } + } + Ok(Some(selected)) +} + +fn canonical_helper(path: &Path) -> Result { + if !path.is_file() { + return Err(format!( + "helper is not an installed file at {}", + path.display() + )); + } + fs::canonicalize(path).map_err(|error| error.to_string()) +} + +fn missing_component(spec: HelperSpec, release: &str) -> Result { + Err(format!( + "rstack {release} selected generation has no usable {} helper; run `rstack install {}` and retry, coordinatorctl will not install components automatically", + spec.binary, + spec.component + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(root: &Path) -> (PathBuf, PathBuf) { + let generation = root.join("generation"); + let selected = generation.join("components/coordinator/coordinator/coordinator"); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"same version\n").unwrap(); + (generation, selected) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_runtime() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path()); + let resolved = resolve_managed_with_environment( + COORDINATOR, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_refuses_a_same_version_runtime_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path()); + let override_path = root.path().join("other-coordinator"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + COORDINATOR, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + assert!(error.contains("COORDINATOR_BIN"), "{error}"); + assert!(error.contains("selected generation"), "{error}"); + assert!(error.contains("v-test"), "{error}"); + } + + #[test] + fn managed_resolution_refuses_a_runtime_environment_outside_the_generation() { + let root = tempfile::tempdir().unwrap(); + let (generation, _) = fixture(root.path()); + let outside = root.path().join("outside-coordinator"); + fs::write(&outside, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + COORDINATOR, + None, + Some(generation.as_os_str()), + Some(outside.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + assert!(error.contains("RSTACK_COORDINATOR_BIN"), "{error}"); + assert!(error.contains("selected generation"), "{error}"); + } + + #[test] + fn managed_resolution_names_the_install_command_when_component_is_missing() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let error = resolve_managed_with_environment( + COORDINATOR, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + assert!(error.contains("rstack install coordinator"), "{error}"); + assert!(error.contains("will not install"), "{error}"); + } + + #[test] + fn unmanaged_resolution_preserves_existing_lookup() { + assert_eq!( + resolve_managed_with_environment(COORDINATOR, None, None, None, None).unwrap(), + None + ); + } +} diff --git a/crates/registry-coordinator/src/operations.rs b/crates/registry-coordinator/src/operations.rs index a199db77ed..a4f917e583 100644 --- a/crates/registry-coordinator/src/operations.rs +++ b/crates/registry-coordinator/src/operations.rs @@ -116,9 +116,9 @@ impl OperationDescriptor { } let mut http = json!({ "maximumResponseBytes":MAXIMUM_HTTP_RESPONSE_BYTES, - "requestTimeoutMilliseconds":HTTP_REQUEST_TIMEOUT_MILLISECONDS, + "attemptTimeoutMilliseconds":HTTP_REQUEST_TIMEOUT_MILLISECONDS, "connectTimeoutMilliseconds":HTTP_CONNECT_TIMEOUT_MILLISECONDS, - "mutationRetries":HTTP_MUTATION_RETRIES + "maximumMutationRetries":HTTP_MUTATION_RETRIES }); if matches!(self.product, "external-http" | "evaluation") { let fields = http.as_object_mut().expect("HTTP contract is an object"); @@ -127,7 +127,7 @@ impl OperationDescriptor { fields.insert("redirects".into(), json!("refused")); if self.product == "evaluation" { fields.insert( - "requestTimeoutMilliseconds".into(), + "attemptTimeoutMilliseconds".into(), json!(EVALUATION_TIMEOUT_MILLISECONDS), ); fields.insert( diff --git a/crates/registry-coordinator/tests/breg_action_adapter.rs b/crates/registry-coordinator/tests/breg_action_adapter.rs index 0902b40a85..ab575bbbf6 100644 --- a/crates/registry-coordinator/tests/breg_action_adapter.rs +++ b/crates/registry-coordinator/tests/breg_action_adapter.rs @@ -45,7 +45,7 @@ fn client(server: &MockServer, token: Arc) -> BaseRegistryClient BaseRegistryClient::new( BaseRegistryClientConfig::new(format!("{}/tenant/base/", server.uri()).parse().unwrap()) .with_token_provider(token) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .unwrap() } diff --git a/crates/registry-coordinator/tests/canonical_model.rs b/crates/registry-coordinator/tests/canonical_model.rs index 775a4d2c6e..615bd9f2a0 100644 --- a/crates/registry-coordinator/tests/canonical_model.rs +++ b/crates/registry-coordinator/tests/canonical_model.rs @@ -531,15 +531,15 @@ fn explain_exposes_the_registered_host_contract_pinned_by_the_operation_revision assert_eq!( expected["limits"]["http"], json!({ - "maximumResponseBytes":65536,"requestTimeoutMilliseconds":2000, - "connectTimeoutMilliseconds":1000,"mutationRetries":0 + "maximumResponseBytes":65536,"attemptTimeoutMilliseconds":2000, + "connectTimeoutMilliseconds":1000,"maximumMutationRetries":0 }) ); let external = registry_coordinator::protocol::Operation::ExternalGet .descriptor() .host_contract(); assert_eq!( - external["limits"]["http"]["requestTimeoutMilliseconds"], + external["limits"]["http"]["attemptTimeoutMilliseconds"], 2000 ); assert_eq!(external["limits"]["http"]["connectionMayLowerLimits"], true); @@ -547,10 +547,16 @@ fn explain_exposes_the_registered_host_contract_pinned_by_the_operation_revision .descriptor() .host_contract(); assert_eq!( - evaluation["limits"]["http"]["requestTimeoutMilliseconds"], + evaluation["limits"]["http"]["attemptTimeoutMilliseconds"], 8000 ); assert_eq!(evaluation["limits"]["http"]["maximumRequestBytes"], 65536); + for contract in [&expected, &external, &evaluation] { + assert!(contract["limits"]["http"] + .get("requestTimeoutMilliseconds") + .is_none()); + assert!(contract["limits"]["http"].get("mutationRetries").is_none()); + } assert_eq!( initial.explain().unwrap()["valueLimits"], expected["limits"]["value"] diff --git a/crates/registry-coordinator/tests/cli_remote.rs b/crates/registry-coordinator/tests/cli_remote.rs index a1ad784685..4465681d0b 100644 --- a/crates/registry-coordinator/tests/cli_remote.rs +++ b/crates/registry-coordinator/tests/cli_remote.rs @@ -456,6 +456,76 @@ async fn a_relative_token_file_is_read_from_the_current_directory() { server.verify().await; } +#[cfg(unix)] +#[test] +fn token_file_refuses_symlinks_and_fifos_without_blocking() { + use std::{ + os::unix::fs::symlink, + process::Stdio, + thread, + time::{Duration, Instant}, + }; + + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("token-target"); + let link = root.path().join("token-link"); + let fifo = root.path().join("token-fifo"); + fs::write(&target, TOKEN).unwrap(); + symlink(&target, &link).unwrap(); + assert!(Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success()); + + for path in [link, fifo] { + let mut child = Command::new(env!("CARGO_BIN_EXE_coordinatorctl")) + .args([ + "--format", + "json", + "status", + "--run", + "00000000-0000-4000-8000-000000000001", + "--url", + "http://127.0.0.1:9", + "--token-file", + ]) + .arg(&path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(2); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + let _ = child.wait(); + panic!("--token-file blocked while opening {}", path.display()); + } + thread::sleep(Duration::from_millis(10)); + }; + assert_eq!(status.code(), Some(1), "{}", path.display()); + let output = child.wait_with_output().unwrap(); + let report: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + report["diagnostics"][0]["code"], + "coordinator.command.input-invalid", + "{}: {report}", + path.display() + ); + let rendered = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!(!rendered.contains(TOKEN), "{}", path.display()); + } +} + #[test] fn a_service_command_names_the_service_argument_it_still_needs() { let root = tempfile::tempdir().unwrap(); diff --git a/crates/registry-coordinator/tests/deployment_postgres.rs b/crates/registry-coordinator/tests/deployment_postgres.rs index 06ee338b60..20e843a290 100644 --- a/crates/registry-coordinator/tests/deployment_postgres.rs +++ b/crates/registry-coordinator/tests/deployment_postgres.rs @@ -4848,7 +4848,7 @@ const assert = require('node:assert/strict'); const { WorkflowClient, workflow, workflowRevision } = require('./js/workflow.js'); const { CoordinatorClient } = require(process.env.COORDINATOR_SDK_NODE_CLIENT); (async () => { - const generic = new CoordinatorClient({baseUrl:process.env.COORDINATOR_SDK_ORIGIN,maxMutationRetries:0}); + const generic = new CoordinatorClient({baseUrl:process.env.COORDINATOR_SDK_ORIGIN,maximumMutationRetries:0}); const client = new WorkflowClient(generic); const token = process.env.COORDINATOR_SDK_TOKEN; const contract = (await generic.workflow(token, workflow)).value; @@ -4887,7 +4887,7 @@ if runtime: ctypes.CDLL(glob.glob(runtime + '/libaws_lc_fips*_crypto.dylib')[0], mode=ctypes.RTLD_GLOBAL) from registry_coordinator_client import CoordinatorClient from workflow import WorkflowClient, WORKFLOW, WORKFLOW_REVISION -generic = CoordinatorClient(os.environ['COORDINATOR_SDK_ORIGIN'], max_mutation_retries=0) +generic = CoordinatorClient(os.environ['COORDINATOR_SDK_ORIGIN'], maximum_mutation_retries=0) client = WorkflowClient(generic) token = os.environ['COORDINATOR_SDK_TOKEN'] contract = generic.workflow(token, WORKFLOW)['value'] diff --git a/crates/registry-coordinator/tests/scheduling_adapter.rs b/crates/registry-coordinator/tests/scheduling_adapter.rs index 3771704e8b..1042a1fb4e 100644 --- a/crates/registry-coordinator/tests/scheduling_adapter.rs +++ b/crates/registry-coordinator/tests/scheduling_adapter.rs @@ -73,7 +73,7 @@ async fn protocol_transient_http_failures_retry_reads_but_hold_ambiguous_mutatio .await; let client = SchedulingClient::new( SchedulingClientConfig::new(product.uri().parse().unwrap()) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .unwrap(); let bearer = BearerToken::new("fixture-token").unwrap(); diff --git a/crates/registry-coordinator/tests/serve.rs b/crates/registry-coordinator/tests/serve.rs new file mode 100644 index 0000000000..d93494f0b4 --- /dev/null +++ b/crates/registry-coordinator/tests/serve.rs @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nif [[ -n \"${{RUNTIME_LEASE_FD:-}}\" ]]; then printf 'lease-inherited\\n' >&\"$RUNTIME_LEASE_FD\"; fi\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_coordinatorctl")) + .args(arguments) + .env("COORDINATOR_BIN", binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("coordinatorctl starts") +} + +fn restore_dynamic_libraries(command: &mut Command) { + for (source, backup) in [ + ( + "DYLD_FALLBACK_LIBRARY_PATH", + "RESTORE_DYLD_FALLBACK_LIBRARY_PATH", + ), + ("DYLD_LIBRARY_PATH", "RESTORE_DYLD_LIBRARY_PATH"), + ("LD_LIBRARY_PATH", "RESTORE_LD_LIBRARY_PATH"), + ] { + if let Some(value) = std::env::var_os(source) { + command.env(backup, value); + } + } +} + +#[test] +fn unmanaged_serve_preserves_runtime_arguments_streams_process_status_and_lease() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "coordinator", 17); + let capture = temp.path().join("capture"); + let lease = temp.path().join("lease"); + let mut launcher = Command::new("/bin/bash"); + launcher + .args([ + "-c", + "exec 9>\"$RUNTIME_LEASE_CAPTURE\"; export RUNTIME_LEASE_FD=9; export DYLD_FALLBACK_LIBRARY_PATH=\"${RESTORE_DYLD_FALLBACK_LIBRARY_PATH:-}\"; export DYLD_LIBRARY_PATH=\"${RESTORE_DYLD_LIBRARY_PATH:-}\"; export LD_LIBRARY_PATH=\"${RESTORE_LD_LIBRARY_PATH:-}\"; exec \"$CTL_BIN\" \"$@\"", + "coordinatorctl-launcher", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ]) + .env("CTL_BIN", env!("CARGO_BIN_EXE_coordinatorctl")) + .env("COORDINATOR_BIN", &binary) + .env("RUNTIME_CAPTURE", &capture) + .env("RUNTIME_LEASE_CAPTURE", &lease) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + restore_dynamic_libraries(&mut launcher); + let child = launcher.spawn().expect("coordinatorctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!( + output.status.code(), + Some(17), + "status={:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!(fs::read_to_string(lease).unwrap(), "lease-inherited\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\n") + ); +} + +#[test] +fn unmanaged_serve_falls_back_to_path_without_a_binary_override() { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), "coordinator", 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_coordinatorctl")) + .args(["serve", "--runtime-config", "/tmp/path.yaml"]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("COORDINATOR_BIN") + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("coordinatorctl starts"); + + assert_eq!(output.status.code(), Some(21)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml"] + ); +} + +#[test] +fn managed_serve_uses_the_selected_generation_instead_of_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/coordinator/coordinator"); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, "coordinator", 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + runtime(&poisoned_directory, "coordinator", 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_coordinatorctl")) + .args(["serve", "--runtime-config", "/tmp/managed.yaml"]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_COORDINATOR_BIN", &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove("COORDINATOR_BIN") + .output() + .expect("coordinatorctl starts"); + + assert_eq!(output.status.code(), Some(19)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml"] + ); +} + +#[test] +fn serve_rejects_json_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "coordinator", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "json", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the runtime must not start"); +} + +#[test] +fn serve_rejects_explicit_human_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "coordinator", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "human", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(!output.stderr.is_empty()); + assert!(!capture.exists(), "the runtime must not start"); +} diff --git a/crates/registry-coordinator/tests/support/breg_service.rs b/crates/registry-coordinator/tests/support/breg_service.rs index bd7ba0529d..a72afc329c 100644 --- a/crates/registry-coordinator/tests/support/breg_service.rs +++ b/crates/registry-coordinator/tests/support/breg_service.rs @@ -434,7 +434,7 @@ impl Breg { BaseRegistryClient::new( BaseRegistryClientConfig::new(base_url.parse().unwrap()) .with_token_provider(std::sync::Arc::new(StaticToken::new(token).unwrap())) - .with_max_mutation_retries(0), + .with_maximum_mutation_retries(0), ) .unwrap() } diff --git a/crates/registry-discovery-client-node/README.md b/crates/registry-discovery-client-node/README.md index 26ec220f0e..ea7a563677 100644 --- a/crates/registry-discovery-client-node/README.md +++ b/crates/registry-discovery-client-node/README.md @@ -4,6 +4,9 @@ Thin napi-rs binding for the bounded Rust `registry-discovery-client` SDK. It performs exact service search, Evidence Type resolution, and ambiguity-safe selection. A returned selection is inert public metadata. The application must apply its own trust policy before calling the selected Evidence endpoint. +Transport configuration uses `attemptTimeoutMilliseconds`, +`connectTimeoutMilliseconds`, and `maximumResponseBytes`; custom roots are PEM +text in `trustedRootCertificates`. This crate publishes through the unified `@registrystack/client` package. Install the exact client version that matches the Discovery deployment, and use diff --git a/crates/registry-discovery-client-node/__test__/surface.test-d.ts b/crates/registry-discovery-client-node/__test__/surface.test-d.ts index ee5344509e..abc3da20fc 100644 --- a/crates/registry-discovery-client-node/__test__/surface.test-d.ts +++ b/crates/registry-discovery-client-node/__test__/surface.test-d.ts @@ -22,7 +22,18 @@ declare function expectType(value: T): void; declare function adopterChooseRecord(items: ServiceRecord[]): ServiceRecord; async function useDiscoveryClient(): Promise { - const client = new DiscoveryClient('https://discovery.example.invalid/'); + const client = new DiscoveryClient({ + baseUrl: 'https://discovery.example.invalid/', + attemptTimeoutMilliseconds: 1500, + maximumResponseBytes: 1500, + trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----', + }); + // @ts-expect-error trust roots are PEM text, not binary data + new DiscoveryClient({ baseUrl: 'https://discovery.example.invalid/', trustedRootCertificates: Buffer.from('certificate') }); + // @ts-expect-error null is not an omission in JavaScript configuration + new DiscoveryClient({ baseUrl: 'https://discovery.example.invalid/', attemptTimeoutMilliseconds: null }); + // @ts-expect-error the per-exchange timeout is named for one attempt + new DiscoveryClient({ baseUrl: 'https://discovery.example.invalid/', requestTimeoutMilliseconds: 1500 }); const resolved = await client.resolveEvidenceTypes({ requirementId: 'urn:example:requirement' }); const context = selectEvidenceAlternative(resolved); const response = await client.searchEvidenceServices({ diff --git a/crates/registry-discovery-client-node/__test__/surface.test.js b/crates/registry-discovery-client-node/__test__/surface.test.js index 71e5da10c4..250dbc723a 100644 --- a/crates/registry-discovery-client-node/__test__/surface.test.js +++ b/crates/registry-discovery-client-node/__test__/surface.test.js @@ -409,11 +409,16 @@ test('binding failures expose a stable value-free kind', () => { test('a configuration value the sanitizer refuses is a configuration error', () => { const baseUrl = 'https://discovery.example.invalid/'; for (const [label, options] of [ - ['an unsafe integer', { baseUrl, requestTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['an unsafe integer', { baseUrl, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['an overflowing response bound', { baseUrl, maximumResponseBytes: 4294967297 }], + ['a fractional connect timeout', { baseUrl, connectTimeoutMilliseconds: 1.5 }], ['a non-finite number', { baseUrl, maximumResponseBytes: Infinity }], - ['an undefined member', { baseUrl, requestTimeoutMilliseconds: undefined }], - ['a function', { baseUrl, requestTimeoutMilliseconds: () => 1500 }], - ['a date', { baseUrl, requestTimeoutMilliseconds: new Date(0) }], + ['an undefined member', { baseUrl, attemptTimeoutMilliseconds: undefined }], + ['a null member', { baseUrl, attemptTimeoutMilliseconds: null }], + ['the retired timeout spelling', { baseUrl, requestTimeoutMilliseconds: 1500 }], + ['a binary trust bundle', { baseUrl, trustedRootCertificates: Buffer.from('not PEM text') }], + ['a function', { baseUrl, attemptTimeoutMilliseconds: () => 1500 }], + ['a date', { baseUrl, attemptTimeoutMilliseconds: new Date(0) }], ['a proxy', new Proxy({ baseUrl }, {})], ['no configuration', undefined], ]) { diff --git a/crates/registry-discovery-client-node/client.d.ts b/crates/registry-discovery-client-node/client.d.ts index 060f8fe36d..61418c562a 100644 --- a/crates/registry-discovery-client-node/client.d.ts +++ b/crates/registry-discovery-client-node/client.d.ts @@ -2,10 +2,10 @@ export type ServiceKind = 'evidence'; export interface DiscoveryClientOptions { baseUrl: string; - requestTimeoutMilliseconds?: number; + attemptTimeoutMilliseconds?: number; connectTimeoutMilliseconds?: number; maximumResponseBytes?: number; - trustedRootCertificates?: Buffer; + trustedRootCertificates?: string; } export interface EvidenceTypeResolveRequest { diff --git a/crates/registry-discovery-client-node/client.js b/crates/registry-discovery-client-node/client.js index 4e1f1b85d9..f3919c6242 100644 --- a/crates/registry-discovery-client-node/client.js +++ b/crates/registry-discovery-client-node/client.js @@ -158,7 +158,7 @@ function configurationValue(options) { } const allowed = new Set([ 'baseUrl', - 'requestTimeoutMilliseconds', + 'attemptTimeoutMilliseconds', 'connectTimeoutMilliseconds', 'maximumResponseBytes', 'trustedRootCertificates', @@ -172,10 +172,10 @@ function configurationValue(options) { } const value = descriptor.value; if (key === 'trustedRootCertificates') { - if (!Buffer.isBuffer(value) || value.length > 4 * 1024 * 1024) { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 4 * 1024 * 1024) { throw inputError('configuration'); } - copy[key] = Buffer.from(value); + copy[key] = value; } else if (key === 'baseUrl') { if (typeof value !== 'string') throw inputError('configuration'); copy[key] = value; diff --git a/crates/registry-discovery-client-node/index.d.ts b/crates/registry-discovery-client-node/index.d.ts index 9ff0e02376..c05f0df901 100644 --- a/crates/registry-discovery-client-node/index.d.ts +++ b/crates/registry-discovery-client-node/index.d.ts @@ -21,10 +21,10 @@ export declare class DiscoveryClient { export interface DiscoveryClientOptions { baseUrl: string - requestTimeoutMilliseconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number maximumResponseBytes?: number - trustedRootCertificates?: Buffer + trustedRootCertificates?: string } export declare function renewUnchangedSelection(previous: any, current: any): any diff --git a/crates/registry-discovery-client-node/src/lib.rs b/crates/registry-discovery-client-node/src/lib.rs index ca857b0ac8..a8cccd30b4 100644 --- a/crates/registry-discovery-client-node/src/lib.rs +++ b/crates/registry-discovery-client-node/src/lib.rs @@ -5,10 +5,7 @@ use std::{sync::Arc, time::Duration}; -use napi::{ - bindgen_prelude::{Buffer, Either}, - Error, Result, -}; +use napi::{bindgen_prelude::Either, Error, Result}; use napi_derive::napi; use registry_discovery_client::{ renew_unchanged_service_selection, validate_service_selection_structure, @@ -25,10 +22,10 @@ const MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BYTES: usize = 4 * 1024 * 1024; #[napi(object)] pub struct DiscoveryClientOptions { pub base_url: String, - pub request_timeout_milliseconds: Option, - pub connect_timeout_milliseconds: Option, - pub maximum_response_bytes: Option, - pub trusted_root_certificates: Option, + pub attempt_timeout_milliseconds: Option, + pub connect_timeout_milliseconds: Option, + pub maximum_response_bytes: Option, + pub trusted_root_certificates: Option, } fn error(source: DiscoveryClientError) -> Error { @@ -112,6 +109,14 @@ fn encode(value: &T) -> Result { serde_json::to_value(value).map_err(|_| error(DiscoveryClientError::Protocol)) } +fn positive_u32(value: f64) -> Result { + if value.is_finite() && value.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(&value) { + Ok(value as u32) + } else { + Err(error(DiscoveryClientError::Configuration)) + } +} + fn selection(response: Value, request: Value) -> Result { let response: ServiceSearchResponse = decode(response)?; let request: SelectionRequest = decode(request)?; @@ -174,7 +179,7 @@ impl DiscoveryClient { let options = match options { Either::A(base_url) => DiscoveryClientOptions { base_url, - request_timeout_milliseconds: None, + attempt_timeout_milliseconds: None, connect_timeout_milliseconds: None, maximum_response_bytes: None, trusted_root_certificates: None, @@ -184,20 +189,24 @@ impl DiscoveryClient { let base_url = url::Url::parse(&options.base_url) .map_err(|_| error(DiscoveryClientError::Configuration))?; let mut config = DiscoveryClientConfig::new(base_url); - if let Some(milliseconds) = options.request_timeout_milliseconds { - config = config.with_request_timeout(Duration::from_millis(milliseconds.into())); + if let Some(milliseconds) = options.attempt_timeout_milliseconds { + config = config.with_attempt_timeout(Duration::from_millis(u64::from(positive_u32( + milliseconds, + )?))); } if let Some(milliseconds) = options.connect_timeout_milliseconds { - config = config.with_connect_timeout(Duration::from_millis(milliseconds.into())); + config = config.with_connect_timeout(Duration::from_millis(u64::from(positive_u32( + milliseconds, + )?))); } if let Some(maximum) = options.maximum_response_bytes { - config = config.with_maximum_response_bytes(maximum.into()); + config = config.with_maximum_response_bytes(u64::from(positive_u32(maximum)?)); } if let Some(certificates) = options.trusted_root_certificates { if certificates.len() > MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BYTES { return Err(error(DiscoveryClientError::Configuration)); } - config = config.with_trusted_root_certificates(certificates.to_vec()); + config = config.with_trusted_root_certificates(certificates.into_bytes()); } Ok(Self { inner: Arc::new(CoreClient::new(config).map_err(error)?), diff --git a/crates/registry-discovery-client-py/README.md b/crates/registry-discovery-client-py/README.md index 574fe5fa03..f823ae9291 100644 --- a/crates/registry-discovery-client-py/README.md +++ b/crates/registry-discovery-client-py/README.md @@ -4,6 +4,8 @@ Synchronous PyO3 binding for the bounded Rust `registry-discovery-client` SDK. It performs exact service search, Evidence Type resolution, and ambiguity-safe selection. A returned selection is inert public metadata. Apply local trust policy before calling its Evidence endpoint. +Transport configuration uses `attempt_timeout_seconds`, +`connect_timeout_seconds`, and `maximum_response_bytes`. This crate publishes through the unified `registry-stack-client` distribution, which imports as `registry_client`. Install the exact client diff --git a/crates/registry-discovery-client-py/python/registry_discovery_client/__init__.pyi b/crates/registry-discovery-client-py/python/registry_discovery_client/__init__.pyi index 1cace894a4..52cbccb48c 100644 --- a/crates/registry-discovery-client-py/python/registry_discovery_client/__init__.pyi +++ b/crates/registry-discovery-client-py/python/registry_discovery_client/__init__.pyi @@ -173,7 +173,7 @@ class DiscoveryClient: def __init__( self, base_url: str, - request_timeout_seconds: float | None = ..., + attempt_timeout_seconds: float | None = ..., connect_timeout_seconds: float | None = ..., maximum_response_bytes: int | None = ..., trusted_root_certificates: bytes | None = ..., diff --git a/crates/registry-discovery-client-py/src/lib.rs b/crates/registry-discovery-client-py/src/lib.rs index f9b1230cd4..7f4dbae8c4 100644 --- a/crates/registry-discovery-client-py/src/lib.rs +++ b/crates/registry-discovery-client-py/src/lib.rs @@ -482,7 +482,7 @@ impl DiscoveryClient { #[new] #[pyo3(signature = ( base_url, - request_timeout_seconds=None, + attempt_timeout_seconds=None, connect_timeout_seconds=None, maximum_response_bytes=None, trusted_root_certificates=None, @@ -490,7 +490,7 @@ impl DiscoveryClient { fn new( py: Python<'_>, base_url: &Bound<'_, PyAny>, - request_timeout_seconds: Option<&Bound<'_, PyAny>>, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, connect_timeout_seconds: Option<&Bound<'_, PyAny>>, maximum_response_bytes: Option<&Bound<'_, PyAny>>, trusted_root_certificates: Option<&Bound<'_, PyAny>>, @@ -508,8 +508,8 @@ impl DiscoveryClient { } let base_url = url::Url::parse(base_url).map_err(|_| configuration_error(py))?; let mut config = DiscoveryClientConfig::new(base_url); - if let Some(timeout) = request_timeout_seconds { - config = config.with_request_timeout( + if let Some(timeout) = attempt_timeout_seconds { + config = config.with_attempt_timeout( positive_duration(timeout).map_err(|_| configuration_error(py))?, ); } diff --git a/crates/registry-discovery-client-py/tests/python/test_client.py b/crates/registry-discovery-client-py/tests/python/test_client.py index c8674be02d..706d932adb 100644 --- a/crates/registry-discovery-client-py/tests/python/test_client.py +++ b/crates/registry-discovery-client-py/tests/python/test_client.py @@ -486,7 +486,7 @@ def test_supported_large_response_remains_selectable(self) -> None: def test_configuration_values_are_bounded_and_value_free(self) -> None: invalid_configurations = [ - {"request_timeout_seconds": 1e300}, + {"attempt_timeout_seconds": 1e300}, {"connect_timeout_seconds": float("inf")}, {"maximum_response_bytes": True}, {"trusted_root_certificates": bytearray(b"not ordinary bytes")}, @@ -509,6 +509,21 @@ def test_configuration_values_are_bounded_and_value_free(self) -> None: "the Discovery client configuration is invalid", ) + def test_none_means_omission_for_optional_configuration(self) -> None: + client = DiscoveryClient( + "https://provider.example.invalid/", + attempt_timeout_seconds=None, + connect_timeout_seconds=None, + maximum_response_bytes=None, + trusted_root_certificates=None, + ) + self.assertIsInstance(client, DiscoveryClient) + + with self.assertRaises(TypeError): + DiscoveryClient( + "https://provider.example.invalid/", request_timeout_seconds=1.0 + ) + def test_query_and_selection_values_use_stable_errors(self) -> None: client = DiscoveryClient("https://provider.example.invalid/") cyclic: dict[str, object] = {} diff --git a/crates/registry-discovery-client-py/tests/python/test_drift.py b/crates/registry-discovery-client-py/tests/python/test_drift.py index 545d01aa1d..286ddcd6e5 100644 --- a/crates/registry-discovery-client-py/tests/python/test_drift.py +++ b/crates/registry-discovery-client-py/tests/python/test_drift.py @@ -120,6 +120,24 @@ def test_runtime_and_stub_export_the_exact_function_and_class_surface(self) -> N with self.subTest(cls=name): self.assertIsInstance(getattr(discovery, name), type) + def test_client_configuration_uses_the_canonical_authored_names(self) -> None: + constructor = next( + node + for node in self.stub_classes["DiscoveryClient"].body + if isinstance(node, ast.FunctionDef) and node.name == "__init__" + ) + self.assertEqual( + [argument.arg for argument in constructor.args.args], + [ + "self", + "base_url", + "attempt_timeout_seconds", + "connect_timeout_seconds", + "maximum_response_bytes", + "trusted_root_certificates", + ], + ) + def test_client_methods_match_the_stub_in_both_directions(self) -> None: self.assertEqual( class_members(self.stub_classes["DiscoveryClient"]), diff --git a/crates/registry-discovery-client/src/client.rs b/crates/registry-discovery-client/src/client.rs index e2d0de13c7..449b12773e 100644 --- a/crates/registry-discovery-client/src/client.rs +++ b/crates/registry-discovery-client/src/client.rs @@ -84,7 +84,7 @@ impl EvidenceServiceQuery { pub struct DiscoveryClientConfig { base_url: url::Url, - request_timeout: Duration, + attempt_timeout: Duration, connect_timeout: Duration, maximum_response_bytes: u64, trusted_root_certificates: Option>>, @@ -95,16 +95,17 @@ impl DiscoveryClientConfig { pub fn new(base_url: url::Url) -> Self { Self { base_url, - request_timeout: Duration::from_secs(30), + attempt_timeout: Duration::from_secs(30), connect_timeout: Duration::from_secs(10), maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, trusted_root_certificates: None, } } + /// Set the timeout for one Discovery HTTP exchange. #[must_use] - pub fn with_request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; + pub fn with_attempt_timeout(mut self, timeout: Duration) -> Self { + self.attempt_timeout = timeout; self } @@ -114,6 +115,7 @@ impl DiscoveryClientConfig { self } + /// Set the response-body ceiling for every exchange. #[must_use] pub fn with_maximum_response_bytes(mut self, maximum: u64) -> Self { self.maximum_response_bytes = maximum; @@ -132,7 +134,7 @@ impl std::fmt::Debug for DiscoveryClientConfig { formatter .debug_struct("DiscoveryClientConfig") .field("base_url", &"") - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) .field("maximum_response_bytes", &self.maximum_response_bytes) .field( @@ -152,7 +154,7 @@ pub struct DiscoveryClient { impl DiscoveryClient { pub fn new(config: DiscoveryClientConfig) -> Result { - if config.request_timeout.is_zero() + if config.attempt_timeout.is_zero() || config.connect_timeout.is_zero() || config.maximum_response_bytes == 0 || config.maximum_response_bytes > 16 * 1024 * 1024 @@ -162,7 +164,7 @@ impl DiscoveryClient { let base_url = ServiceBaseUrl::new(config.base_url) .map_err(|_| DiscoveryClientError::Configuration)?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: Some("registry-discovery-client"), trusted_root_certificates: config diff --git a/crates/registry-discovery-client/tests/native_journey.rs b/crates/registry-discovery-client/tests/native_journey.rs index e916772709..22ab24c568 100644 --- a/crates/registry-discovery-client/tests/native_journey.rs +++ b/crates/registry-discovery-client/tests/native_journey.rs @@ -573,7 +573,7 @@ async fn complete_evidence_journey_builds_selects_trusts_and_invokes_a_signed_as let client = DiscoveryClient::new( DiscoveryClientConfig::new(discovery.base_url.clone()) .with_connect_timeout(Duration::from_secs(1)) - .with_request_timeout(Duration::from_secs(1)), + .with_attempt_timeout(Duration::from_secs(1)), ) .expect("the bounded local Discovery client builds"); let resolved = client diff --git a/crates/registry-discoveryctl/src/lib.rs b/crates/registry-discoveryctl/src/lib.rs index 0b7e8dec0a..f7099d25f5 100644 --- a/crates/registry-discoveryctl/src/lib.rs +++ b/crates/registry-discoveryctl/src/lib.rs @@ -4,9 +4,9 @@ use std::ffi::{OsStr, OsString}; use std::io; use std::path::PathBuf; -use std::process::ExitCode; +use std::process::{Command as ProcessCommand, ExitCode}; -use clap::{ArgGroup, Parser, Subcommand, ValueEnum}; +use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum}; use registry_platform_yaml::{Diagnostic, Report}; use serde_json::{json, Value}; @@ -16,6 +16,7 @@ mod project; mod report; #[cfg(feature = "schema")] pub mod schema; +mod selected_binary; pub use build::{package_project, package_project_at, BuildError, PackagedDiscovery}; pub use index::{inspect_index_file, IndexReport}; @@ -54,6 +55,8 @@ enum OutputFormat { #[derive(Debug, Subcommand)] enum Command { + /// Run the production Discovery service with the selected runtime. + Serve(ServeArgs), /// Check an authoring project, a runtime file, or an index without /// network I/O. #[command(group( @@ -108,6 +111,17 @@ enum Command { }, } +#[derive(Debug, Args)] +struct ServeArgs { + /// Runtime configuration file passed unchanged to the Discovery runtime. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Discovery runtime executable for an unmanaged installation. + #[arg(long, hide = true, env = "DISCOVERY_BIN", value_name = "BINARY")] + discovery_bin: Option, +} + const RETIRED_BUILD_ERROR: &str = "discoveryctl build was removed; use discoveryctl package with a new output directory"; const DOMAIN_REFUSAL_EXIT: u8 = 1; @@ -165,6 +179,7 @@ where } }; match arguments.command { + Command::Serve(args) => serve(args, stderr), Command::Check { project, runtime_config, @@ -324,6 +339,7 @@ fn envelope(command: &str, exit: u8, mut body: Value) -> Value { fn run(command: Command) -> Result { match command { + Command::Serve(_) => unreachable!("serve replaces the ctl process before report dispatch"), Command::Check { .. } => unreachable!("check reports through its own path"), Command::Package { project, @@ -356,6 +372,61 @@ fn run(command: Command) -> Result { } } +fn serve(args: ServeArgs, stderr: &mut dyn io::Write) -> ExitCode { + let binary = match selected_binary::resolve_managed( + selected_binary::DISCOVERY, + args.discovery_bin.as_deref(), + ) { + Ok(Some(binary)) => binary, + Ok(None) => args + .discovery_bin + .unwrap_or_else(|| PathBuf::from("discovery")), + Err(error) => { + let _ = writeln!( + stderr, + "discoveryctl serve could not select discovery: {error}" + ); + return ExitCode::from(report::OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + command.arg("--runtime-config").arg(args.runtime_config); + replace_with_service(command, "discovery", stderr) +} + +#[cfg(unix)] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn io::Write, +) -> ExitCode { + use std::os::unix::process::CommandExt as _; + + let error = command.exec(); + let _ = writeln!(stderr, "discoveryctl serve could not start {name}: {error}"); + ExitCode::from(report::OPERATIONAL_FAILURE_EXIT) +} + +#[cfg(not(unix))] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn io::Write, +) -> ExitCode { + match command.status() { + Ok(status) => ExitCode::from( + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(report::OPERATIONAL_FAILURE_EXIT), + ), + Err(error) => { + let _ = writeln!(stderr, "discoveryctl serve could not start {name}: {error}"); + ExitCode::from(report::OPERATIONAL_FAILURE_EXIT) + } + } +} + #[cfg(test)] mod tests { use std::fs; diff --git a/crates/registry-discoveryctl/src/selected_binary.rs b/crates/registry-discoveryctl/src/selected_binary.rs new file mode 100644 index 0000000000..c14bffb657 --- /dev/null +++ b/crates/registry-discoveryctl/src/selected_binary.rs @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +type Result = std::result::Result; + +#[derive(Clone, Copy)] +pub(crate) struct HelperSpec { + pub(crate) binary: &'static str, + pub(crate) component: &'static str, + pub(crate) explicit_source: &'static str, + pub(crate) managed_env: &'static str, +} + +pub(crate) const DISCOVERY: HelperSpec = HelperSpec { + binary: "discovery", + component: "discovery", + explicit_source: "--discovery-bin or DISCOVERY_BIN", + managed_env: "RSTACK_DISCOVERY_BIN", +}; + +pub(crate) fn resolve_managed( + spec: HelperSpec, + explicit: Option<&Path>, +) -> Result> { + resolve_managed_with_environment( + spec, + explicit, + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(spec.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + ) +} + +fn resolve_managed_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result> { + let Some(generation_dir) = generation_dir else { + return Ok(None); + }; + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(Path::new(generation_dir)).map_err(|_| { + format!( + "rstack {release} selected generation {} cannot be resolved", + Path::new(generation_dir).display() + ) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let Some(managed_path) = managed_path else { + return missing_component(spec, &release); + }; + let selected = + canonical_helper(Path::new(managed_path)).or_else(|_| missing_component(spec, &release))?; + let layout = canonical_helper(&layout_path).or_else(|_| missing_component(spec, &release))?; + if selected != layout { + return Err(format!( + "rstack {release} provided {} outside the selected generation helper path at {}; run `rstack install {}` to repair the selected generation", + spec.managed_env, + layout_path.display(), + spec.component + )); + } + if let Some(override_path) = explicit { + let override_identity = canonical_helper(override_path).map_err(|_| { + format!( + "cannot resolve {} from {}", + spec.binary, spec.explicit_source + ) + })?; + if override_identity != selected { + return Err(format!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected generation identity", + spec.explicit_source, + override_path.display(), + spec.binary, + selected.display() + )); + } + } + Ok(Some(selected)) +} + +fn canonical_helper(path: &Path) -> Result { + if !path.is_file() { + return Err(format!( + "helper is not an installed file at {}", + path.display() + )); + } + fs::canonicalize(path).map_err(|error| error.to_string()) +} + +fn missing_component(spec: HelperSpec, release: &str) -> Result { + Err(format!( + "rstack {release} selected generation has no usable {} helper; run `rstack install {}` and retry, discoveryctl will not install components automatically", + spec.binary, + spec.component + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(root: &Path) -> (PathBuf, PathBuf) { + let generation = root.join("generation"); + let selected = generation.join("components/discovery/discovery/discovery"); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"same version\n").unwrap(); + (generation, selected) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_runtime() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path()); + let resolved = resolve_managed_with_environment( + DISCOVERY, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_refuses_a_same_version_runtime_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path()); + let override_path = root.path().join("other-discovery"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + DISCOVERY, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + assert!(error.contains("DISCOVERY_BIN"), "{error}"); + assert!(error.contains("selected generation"), "{error}"); + assert!(error.contains("v-test"), "{error}"); + } + + #[test] + fn managed_resolution_refuses_a_runtime_environment_outside_the_generation() { + let root = tempfile::tempdir().unwrap(); + let (generation, _) = fixture(root.path()); + let outside = root.path().join("outside-discovery"); + fs::write(&outside, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + DISCOVERY, + None, + Some(generation.as_os_str()), + Some(outside.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + assert!(error.contains("RSTACK_DISCOVERY_BIN"), "{error}"); + assert!(error.contains("selected generation"), "{error}"); + } + + #[test] + fn managed_resolution_names_the_install_command_when_component_is_missing() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let error = resolve_managed_with_environment( + DISCOVERY, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + assert!(error.contains("rstack install discovery"), "{error}"); + assert!(error.contains("will not install"), "{error}"); + } + + #[test] + fn unmanaged_resolution_preserves_existing_lookup() { + assert_eq!( + resolve_managed_with_environment(DISCOVERY, None, None, None, None).unwrap(), + None + ); + } +} diff --git a/crates/registry-discoveryctl/tests/serve.rs b/crates/registry-discoveryctl/tests/serve.rs new file mode 100644 index 0000000000..8937911b4e --- /dev/null +++ b/crates/registry-discoveryctl/tests/serve.rs @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nif [[ -n \"${{RUNTIME_LEASE_FD:-}}\" ]]; then printf 'lease-inherited\\n' >&\"$RUNTIME_LEASE_FD\"; fi\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_discoveryctl")) + .args(arguments) + .env("DISCOVERY_BIN", binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("discoveryctl starts") +} + +fn restore_dynamic_libraries(command: &mut Command) { + for (source, backup) in [ + ( + "DYLD_FALLBACK_LIBRARY_PATH", + "RESTORE_DYLD_FALLBACK_LIBRARY_PATH", + ), + ("DYLD_LIBRARY_PATH", "RESTORE_DYLD_LIBRARY_PATH"), + ("LD_LIBRARY_PATH", "RESTORE_LD_LIBRARY_PATH"), + ] { + if let Some(value) = std::env::var_os(source) { + command.env(backup, value); + } + } +} + +#[test] +fn unmanaged_serve_preserves_runtime_arguments_streams_process_status_and_lease() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "discovery", 17); + let capture = temp.path().join("capture"); + let lease = temp.path().join("lease"); + let mut launcher = Command::new("/bin/bash"); + launcher + .args([ + "-c", + "exec 9>\"$RUNTIME_LEASE_CAPTURE\"; export RUNTIME_LEASE_FD=9; export DYLD_FALLBACK_LIBRARY_PATH=\"${RESTORE_DYLD_FALLBACK_LIBRARY_PATH:-}\"; export DYLD_LIBRARY_PATH=\"${RESTORE_DYLD_LIBRARY_PATH:-}\"; export LD_LIBRARY_PATH=\"${RESTORE_LD_LIBRARY_PATH:-}\"; exec \"$CTL_BIN\" \"$@\"", + "discoveryctl-launcher", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ]) + .env("CTL_BIN", env!("CARGO_BIN_EXE_discoveryctl")) + .env("DISCOVERY_BIN", &binary) + .env("RUNTIME_CAPTURE", &capture) + .env("RUNTIME_LEASE_CAPTURE", &lease) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + restore_dynamic_libraries(&mut launcher); + let child = launcher.spawn().expect("discoveryctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!( + output.status.code(), + Some(17), + "status={:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!(fs::read_to_string(lease).unwrap(), "lease-inherited\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\n") + ); +} + +#[test] +fn unmanaged_serve_falls_back_to_path_without_a_binary_override() { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), "discovery", 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_discoveryctl")) + .args(["serve", "--runtime-config", "/tmp/path.yaml"]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("DISCOVERY_BIN") + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("discoveryctl starts"); + + assert_eq!(output.status.code(), Some(21)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml"] + ); +} + +#[test] +fn managed_serve_uses_the_selected_generation_instead_of_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/discovery/discovery"); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, "discovery", 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + runtime(&poisoned_directory, "discovery", 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_discoveryctl")) + .args(["serve", "--runtime-config", "/tmp/managed.yaml"]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_DISCOVERY_BIN", &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove("DISCOVERY_BIN") + .output() + .expect("discoveryctl starts"); + + assert_eq!(output.status.code(), Some(19)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml"] + ); +} + +#[test] +fn serve_rejects_json_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "discovery", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "json", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the runtime must not start"); +} + +#[test] +fn serve_rejects_explicit_human_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "discovery", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "human", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(!output.stderr.is_empty()); + assert!(!capture.exists(), "the runtime must not start"); +} diff --git a/crates/registry-evidence-client-node/README.md b/crates/registry-evidence-client-node/README.md index c0acd75ad8..cd265ed162 100644 --- a/crates/registry-evidence-client-node/README.md +++ b/crates/registry-evidence-client-node/README.md @@ -159,13 +159,18 @@ A response that exceeds its size bound maps to `kind: "transport"` with response status itself was a plain 200: the size limit is enforced against the transport, before any attempt to interpret the body as a problem response. -Which bound applies depends on the call. `maxResponseBytes` bounds the signed -response body that `send()` and `requestAndVerify()` read, and its default -follows what the verifier will accept as a signed response. Request-batch -responses use the smaller of `maxResponseBytes` and the protocol's independent -1 MiB envelope ceiling. `maxMetadataBytes` bounds the documents `discover()` -and `fetchJwks()` read, neither of which is signed or verified. Tightening one -does not tighten the other. +Which bound applies depends on the call. `maximumResponseBytes` bounds the +signed response body that `send()` and `requestAndVerify()` read, and its +default follows what the verifier will accept as a signed response. +Request-batch responses use the smaller of `maximumResponseBytes` and the +protocol's independent 1 MiB envelope ceiling. `maxMetadataBytes` bounds the +documents `discover()` and `fetchJwks()` read, neither of which is signed or +verified. Tightening one does not tighten the other. The retired +`maxResponseBytes` spelling is refused, and optional configuration values are +omitted rather than set to `null`. +`attemptTimeoutMilliseconds` bounds one outbound attempt, including its body, +and `connectTimeoutMilliseconds` bounds connection setup. Direct private-key +JWT and exchange providers use the same timeout names for their own requests. ### Nonce and the golden fixture diff --git a/crates/registry-evidence-client-node/__test__/construction.test.js b/crates/registry-evidence-client-node/__test__/construction.test.js index 374f54f91f..58f8622c3a 100644 --- a/crates/registry-evidence-client-node/__test__/construction.test.js +++ b/crates/registry-evidence-client-node/__test__/construction.test.js @@ -48,6 +48,13 @@ test('a non-HTTPS, non-loopback base URL is refused', () => { ); }); +test('canonical attempt and connection timeout options construct offline', () => { + assert.ok(new EvidenceClient(validConfig({ + attemptTimeoutMilliseconds: 5_000, + connectTimeoutMilliseconds: 2_000, + })) instanceof EvidenceClient); +}); + test('exchange authorization constructs offline and rejects an ambiguous token', () => { const key = () => ({ ...crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey.export({ format: 'jwk' }), @@ -66,6 +73,20 @@ test('exchange authorization constructs offline and rejects an ambiguous token', assertConfigurationRefusal(() => new EvidenceClient(config)); delete config.token; assert.ok(new EvidenceClient(config) instanceof EvidenceClient); + + for (const exchange of [ + { ...config.authorization.exchange, context: { ...config.authorization.exchange.context, grantId: null } }, + { ...config.authorization.exchange, client: { ...config.authorization.exchange.client, + attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 } }, + { ...config.authorization.exchange, client: { ...config.authorization.exchange.client, + requestTimeoutMilliseconds: 100 } }, + { ...config.authorization.exchange, client: { ...config.authorization.exchange.client, + attemptTimeoutSeconds: 0.1 } }, + { ...config.authorization.exchange, client: { ...config.authorization.exchange.client, + request_timeout_milliseconds: 100 } }, + ]) { + assertConfigurationRefusal(() => new EvidenceClient({ ...config, authorization: { exchange } })); + } }); test('an empty trusted key set is refused', () => { @@ -89,13 +110,48 @@ test('a base URL with an empty path segment is refused', () => { }); test('a numeric setting that is not a whole number in range is refused', () => { - for (const member of ['requestTimeoutMs', 'connectTimeoutMs', 'maxResponseBytes', 'maxMetadataBytes']) { + for (const member of ['attemptTimeoutMilliseconds', 'connectTimeoutMilliseconds', 'maximumResponseBytes', 'maxMetadataBytes']) { for (const value of [Number.MAX_SAFE_INTEGER + 1, -1, 1.5, '1500', new Date(0)]) { assertConfigurationRefusal(() => new EvidenceClient(validConfig({ [member]: value }))); } } }); +test('unknown, retired, null, and binary configuration members are refused', () => { + for (const options of [ + { requestTimeoutMs: 1_500 }, + { connectTimeoutMs: 1_500 }, + { maximumResponseByte: 1024 }, + { maxResponseBytes: 1024 }, + { maximumResponseBytes: null }, + { userAgent: null }, + ]) { + assertConfigurationRefusal(() => new EvidenceClient(validConfig(options))); + } + assert.throws(() => new EvidenceClient(validConfig({ + trustedRootCertificates: Buffer.from('not PEM text'), + }))); +}); + +test('direct private-key JWT timeouts use attempt and connection names', () => { + const clientKey = { + ...crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).privateKey.export({ format: 'jwk' }), + alg: 'ES256', kid: 'synthetic-direct-provider-test', + }; + const token = { privateKeyJwt: { + tokenEndpoint: 'https://issuer.example/token', + clientId: 'direct-client', + clientKey, + attemptTimeoutMilliseconds: 5_000, + connectTimeoutMilliseconds: 2_000, + } }; + assert.ok(new EvidenceClient(validConfig({ token })) instanceof EvidenceClient); + assertConfigurationRefusal(() => new EvidenceClient(validConfig({ token: { privateKeyJwt: { + ...token.privateKeyJwt, + requestTimeoutMs: 5_000, + } } }))); +}); + test('fromProfile returns the public wrapper and preserves consumer subclasses', () => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'evidence-client-node-profile-')); try { diff --git a/crates/registry-evidence-client-node/__test__/discovery.test.js b/crates/registry-evidence-client-node/__test__/discovery.test.js index 04c1593f3b..d4bb385649 100644 --- a/crates/registry-evidence-client-node/__test__/discovery.test.js +++ b/crates/registry-evidence-client-node/__test__/discovery.test.js @@ -90,14 +90,14 @@ test('discover reads a valid definitions document from a stub deployment', async } }); -// The two bounds answer different questions: `maxResponseBytes` is derived from +// The two bounds answer different questions: `maximumResponseBytes` is derived from // what the verifier accepts as a signed response, and the discovery document is // neither signed nor verified. Tightening one must not silently disable the // other endpoint. test('the metadata bound governs discovery, and the signed response bound does not', async () => { const stub = await definitionsStub(); try { - const document = await clientAgainst(stub, { maxResponseBytes: 1 }).discover(); + const document = await clientAgainst(stub, { maximumResponseBytes: 1 }).discover(); assert.equal(document.definitions.length, 1); const bounded = clientAgainst(stub, { diff --git a/crates/registry-evidence-client-node/__test__/drift.test.js b/crates/registry-evidence-client-node/__test__/drift.test.js index 8d7a7ce5f3..9911ce5ae5 100644 --- a/crates/registry-evidence-client-node/__test__/drift.test.js +++ b/crates/registry-evidence-client-node/__test__/drift.test.js @@ -87,12 +87,10 @@ function configurationKeysRead(functionName) { // The exchange configuration members `client.d.ts` names by hand are read by // `exchange_config.rs` in `registry-platform-httputil`, not by `src/convert.rs`: -// the binding hands that object through unchanged. That parser is -// `deny_unknown_fields` over `rename_all = "camelCase"`, so a member the stub -// declares but the parser does not accept type-checks and then fails at run -// time as a malformed configuration, and a member the parser accepts but the -// stub omits is unreachable for a TypeScript caller. The struct's own fields -// are the list. +// the binding hands that object through after enforcing the Node spelling. +// The shared parser also accepts second-valued snake-case fields for Python; +// those serialize to camel case internally but are deliberately not part of +// the public Node surface. The remaining struct fields are the Node list. function exchangeParserFields(structName) { const source = fs.readFileSync( path.join(__dirname, '..', '..', 'registry-platform-httputil', 'src', 'client', 'exchange_config.rs'), @@ -103,6 +101,7 @@ function exchangeParserFields(structName) { const body = source.slice(start, source.indexOf('\n}\n', start)); return [...body.matchAll(/^ (\w+): /gm)] .map((match) => match[1].replace(/_(\w)/g, (_, letter) => letter.toUpperCase())) + .filter((field) => !['attemptTimeoutSeconds', 'connectTimeoutSeconds'].includes(field)) .sort(); } diff --git a/crates/registry-evidence-client-node/__test__/errors.test.js b/crates/registry-evidence-client-node/__test__/errors.test.js index 925185f180..e79481af16 100644 --- a/crates/registry-evidence-client-node/__test__/errors.test.js +++ b/crates/registry-evidence-client-node/__test__/errors.test.js @@ -173,7 +173,7 @@ test('a 200 response under the wrong media type is refused as a protocol failure }); }); -test('a response over maxResponseBytes is refused as a transport failure, not a protocol failure', async () => { +test('a response over maximumResponseBytes is refused as a transport failure, not a protocol failure', async () => { const oversized = Buffer.alloc(4096, 'a'); const stub = await startStubServer({ 'POST /v1/evidence': (req, res) => { @@ -190,7 +190,7 @@ test('a response over maxResponseBytes is refused as a transport failure, not a trustedJwks: DUMMY_JWKS, revokedKeyIds: [], token: { static: 'errors-test-token' }, - maxResponseBytes: 16, + maximumResponseBytes: 16, }); const prepared = client.prepare(requestSpec()); await assert.rejects(client.send(prepared), (error) => { diff --git a/crates/registry-evidence-client-node/client.d.ts b/crates/registry-evidence-client-node/client.d.ts index 65f7629b25..93de20ddd6 100644 --- a/crates/registry-evidence-client-node/client.d.ts +++ b/crates/registry-evidence-client-node/client.d.ts @@ -120,8 +120,8 @@ export interface PrivateKeyJwtConfig { scopes?: ReadonlyArray assertionLifetimeSeconds?: number refreshMarginSeconds?: number - requestTimeoutMs?: number - connectTimeoutMs?: number + attemptTimeoutMilliseconds?: number + connectTimeoutMilliseconds?: number userAgent?: string /** Additional trust roots for the token endpoint, as a PEM bundle. */ trustedRootCertificates?: string @@ -151,11 +151,8 @@ export interface EvidenceExchangeContext { * This is not a `PrivateKeyJwtConfig`, even though the two describe the same * kind of client: an exchange is pinned to one resource and scope set, so both * are required here, and the object is read by the shared exchange - * configuration parser rather than by the binding's own reader, so its timeout - * members are the full-word ones that parser accepts. State at most one of - * `requestTimeoutMilliseconds` and `requestTimeoutSeconds`, and at most one of - * `connectTimeoutMilliseconds` and `connectTimeoutSeconds`; naming both of a - * pair is refused. + * configuration parser rather than by the binding's own reader. Node exposes + * both timeout controls in milliseconds. */ export interface EvidenceExchangeClientConfig { tokenEndpoint: string @@ -180,10 +177,8 @@ export interface EvidenceExchangeClientConfig { scopes: ReadonlyArray assertionLifetimeSeconds?: number refreshMarginSeconds?: number - requestTimeoutMilliseconds?: number - requestTimeoutSeconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number - connectTimeoutSeconds?: number userAgent?: string /** Additional trust roots for the token endpoint, as a PEM bundle. */ trustedRootCertificates?: string @@ -195,10 +190,8 @@ export interface EvidenceExchangeRemoteSourceConfig { bootstrap: EvidenceExchangeClientConfig bootstrapResource: string bootstrapScope: string - requestTimeoutMilliseconds?: number - requestTimeoutSeconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number - connectTimeoutSeconds?: number userAgent?: string /** Additional trust roots for the authority, as a PEM bundle. */ trustedRootCertificates?: string @@ -220,12 +213,12 @@ export interface EvidenceClientConfig { /** Configure exactly one of `token` or `authorization`. */ token?: EvidenceTokenConfig authorization?: { exchange: EvidenceExchangeAuthorizationConfig } - requestTimeoutMs?: number - connectTimeoutMs?: number + attemptTimeoutMilliseconds?: number + connectTimeoutMilliseconds?: number userAgent?: string /** Additional trust roots for the deployment, as a PEM bundle. */ trustedRootCertificates?: string - maxResponseBytes?: number + maximumResponseBytes?: number maxMetadataBytes?: number } diff --git a/crates/registry-evidence-client-node/index.d.ts b/crates/registry-evidence-client-node/index.d.ts index d5537b77f8..c01b73fad5 100644 --- a/crates/registry-evidence-client-node/index.d.ts +++ b/crates/registry-evidence-client-node/index.d.ts @@ -34,7 +34,7 @@ export declare class EvidenceClient { * mandatory trust inputs. A key set or revoked-key list the verifier could * never use is refused, exactly as the Rust configuration refuses it. * - * `maxResponseBytes` bounds the signed response `send` reads. + * `maximumResponseBytes` bounds the signed response `send` reads. * `maxMetadataBytes` bounds the documents `discover` and `fetchJwks` read, * which are neither signed nor verified, and is a separate decision. */ diff --git a/crates/registry-evidence-client-node/src/convert.rs b/crates/registry-evidence-client-node/src/convert.rs index 5c54736e89..f7e7cfec29 100644 --- a/crates/registry-evidence-client-node/src/convert.rs +++ b/crates/registry-evidence-client-node/src/convert.rs @@ -22,6 +22,8 @@ use registry_platform_httputil::{exchange_authorization_from_json, ExchangeAutho use serde_json::{Map, Value}; use url::Url; +const MAXIMUM_JAVASCRIPT_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + /// A JS-supplied value did not have the shape this binding requires. /// /// This is distinct from [`EvidenceClientError`]: it is refused before any @@ -90,6 +92,22 @@ fn require_exact_fields( Ok(()) } +fn require_omitted_instead_of_null( + object: &Map, + fields: &[&str], + what: &str, +) -> Result<(), ConversionError> { + if fields + .iter() + .any(|field| matches!(object.get(*field), Some(Value::Null))) + { + return Err(ConversionError::new(format!( + "{what} options must be omitted instead of null" + ))); + } + Ok(()) +} + fn required_string(object: &Map, field: &str) -> Result { object .get(field) @@ -102,6 +120,7 @@ fn required_u64(object: &Map, field: &str) -> Result Result, ConversionError> { match object.get(field) { - None | Some(Value::Null) => Ok(None), + None => Ok(None), Some(Value::String(text)) => Ok(Some(text.clone())), Some(_) => Err(ConversionError::new(format!("`{field}` must be a string"))), } @@ -144,9 +163,6 @@ fn optional_string_array( let Some(values) = object.get(field) else { return Ok(None); }; - if values.is_null() { - return Ok(None); - } let values = values .as_array() .ok_or_else(|| ConversionError::new(format!("`{field}` must be an array of strings")))?; @@ -164,20 +180,153 @@ fn optional_string_array( fn optional_u64(object: &Map, field: &str) -> Result, ConversionError> { match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(value) => value.as_u64().map(Some).ok_or_else(|| { - ConversionError::new(format!("`{field}` must be a non-negative integer")) - }), + None => Ok(None), + Some(value) => value + .as_u64() + .filter(|value| *value <= MAXIMUM_JAVASCRIPT_SAFE_INTEGER) + .map(Some) + .ok_or_else(|| { + ConversionError::new(format!("`{field}` must be a non-negative safe integer")) + }), } } fn optional_i64(object: &Map, field: &str) -> Result, ConversionError> { match object.get(field) { - None | Some(Value::Null) => Ok(None), - Some(value) => value.as_i64().map(Some).ok_or_else(|| { - ConversionError::new(format!("`{field}` must be an integer that fits in 64 bits")) - }), + None => Ok(None), + Some(value) => value + .as_i64() + .filter(|value| value.unsigned_abs() <= MAXIMUM_JAVASCRIPT_SAFE_INTEGER) + .map(Some) + .ok_or_else(|| ConversionError::new(format!("`{field}` must be a safe integer"))), + } +} + +fn validate_exchange_client(value: &Value, what: &str) -> Result<(), ConversionError> { + let object = as_object(value, what)?; + require_exact_fields( + object, + &[ + "tokenEndpoint", + "clientId", + "clientKey", + "audience", + "resource", + "scopes", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + what, + )?; + require_omitted_instead_of_null( + object, + &[ + "audience", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + what, + )?; + optional_i64(object, "assertionLifetimeSeconds")?; + optional_i64(object, "refreshMarginSeconds")?; + optional_u64(object, "attemptTimeoutMilliseconds")?; + optional_u64(object, "connectTimeoutMilliseconds")?; + Ok(()) +} + +fn validate_exchange_config(value: &Value) -> Result<(), ConversionError> { + let object = as_object(value, "`authorization.exchange`")?; + require_exact_fields( + object, + &["client", "context", "firstParty", "remote"], + "`authorization.exchange`", + )?; + require_omitted_instead_of_null( + object, + &["firstParty", "remote"], + "`authorization.exchange`", + )?; + + validate_exchange_client( + object.get("client").ok_or_else(|| { + ConversionError::new("`authorization.exchange.client` must be present") + })?, + "`authorization.exchange.client`", + )?; + + let context = as_object( + object.get("context").ok_or_else(|| { + ConversionError::new("`authorization.exchange.context` must be present") + })?, + "`authorization.exchange.context`", + )?; + require_exact_fields( + context, + &[ + "issuer", + "subject", + "audience", + "generation", + "deadlineSeconds", + "grantId", + ], + "`authorization.exchange.context`", + )?; + require_omitted_instead_of_null(context, &["grantId"], "`authorization.exchange.context`")?; + required_u64(context, "deadlineSeconds")?; + + if let Some(first_party) = object.get("firstParty") { + let first_party = as_object(first_party, "`authorization.exchange.firstParty`")?; + require_exact_fields( + first_party, + &["key", "attributes"], + "`authorization.exchange.firstParty`", + )?; + } + if let Some(remote) = object.get("remote") { + let remote = as_object(remote, "`authorization.exchange.remote`")?; + require_exact_fields( + remote, + &[ + "endpoint", + "bootstrap", + "bootstrapResource", + "bootstrapScope", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "`authorization.exchange.remote`", + )?; + require_omitted_instead_of_null( + remote, + &[ + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "`authorization.exchange.remote`", + )?; + validate_exchange_client( + remote.get("bootstrap").ok_or_else(|| { + ConversionError::new("`authorization.exchange.remote.bootstrap` must be present") + })?, + "`authorization.exchange.remote.bootstrap`", + )?; + optional_u64(remote, "attemptTimeoutMilliseconds")?; + optional_u64(remote, "connectTimeoutMilliseconds")?; } + Ok(()) } fn parse_url(value: &str, what: &str) -> Result { @@ -617,6 +766,41 @@ pub fn batch_spec_from_json(value: &Value) -> Result Result { let object = as_object(value, "`token.privateKeyJwt`").map_err(ConfigError::Shape)?; + require_exact_fields( + object, + &[ + "tokenEndpoint", + "clientId", + "clientKey", + "audience", + "resource", + "scopes", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "`token.privateKeyJwt`", + ) + .map_err(ConfigError::Shape)?; + require_omitted_instead_of_null( + object, + &[ + "audience", + "resource", + "scopes", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "`token.privateKeyJwt`", + ) + .map_err(ConfigError::Shape)?; let token_endpoint = parse_url( &required_string(object, "tokenEndpoint").map_err(ConfigError::Shape)?, @@ -661,10 +845,14 @@ fn private_key_jwt_provider_from_json(value: &Value) -> Result Result { let object = as_object(value, "the client configuration").map_err(ConfigError::Shape)?; + require_exact_fields( + object, + &[ + "baseUrl", + "trustedJwks", + "revokedKeyIds", + "token", + "authorization", + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + "maximumResponseBytes", + "maxMetadataBytes", + ], + "the client configuration", + ) + .map_err(ConfigError::Shape)?; + require_omitted_instead_of_null( + object, + &[ + "attemptTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + "maximumResponseBytes", + "maxMetadataBytes", + ], + "client configuration", + ) + .map_err(ConfigError::Shape)?; let base_url = parse_url( &required_string(object, "baseUrl").map_err(ConfigError::Shape)?, @@ -770,10 +990,14 @@ pub fn config_from_json(value: &Value) -> Result Result None: ... diff --git a/crates/registry-evidence-client-py/src/convert.rs b/crates/registry-evidence-client-py/src/convert.rs index 94a99f66a4..80717f0060 100644 --- a/crates/registry-evidence-client-py/src/convert.rs +++ b/crates/registry-evidence-client-py/src/convert.rs @@ -775,14 +775,14 @@ fn private_key_jwt_provider_from_json(value: &Value) -> Result, token: &Value, - request_timeout_seconds: Option, + attempt_timeout_seconds: Option, connect_timeout_seconds: Option, user_agent: Option, trusted_root_certificates: Option>, - max_response_bytes: Option, + maximum_response_bytes: Option, max_metadata_bytes: Option, ) -> Result { config_from_parts_with_authorization( @@ -862,27 +862,65 @@ pub fn config_from_parts( revoked_key_ids, token, None, - request_timeout_seconds, + attempt_timeout_seconds, connect_timeout_seconds, user_agent, trusted_root_certificates, - max_response_bytes, + maximum_response_bytes, max_metadata_bytes, ) } #[allow(clippy::too_many_arguments)] +#[cfg(test)] pub fn config_from_parts_with_authorization( base_url: &str, trusted_jwks: &Value, revoked_key_ids: Vec, token: &Value, authorization: Option<&Value>, - request_timeout_seconds: Option, + attempt_timeout_seconds: Option, connect_timeout_seconds: Option, user_agent: Option, trusted_root_certificates: Option>, - max_response_bytes: Option, + maximum_response_bytes: Option, + max_metadata_bytes: Option, +) -> Result { + let request_timeout = attempt_timeout_seconds + .map(|seconds| duration_from_seconds(seconds, "`attempt_timeout_seconds`")) + .transpose() + .map_err(ConfigError::Shape)?; + let connect_timeout = connect_timeout_seconds + .map(|seconds| duration_from_seconds(seconds, "`connect_timeout_seconds`")) + .transpose() + .map_err(ConfigError::Shape)?; + config_from_parts_with_authorization_durations( + base_url, + trusted_jwks, + revoked_key_ids, + token, + authorization, + request_timeout, + connect_timeout, + user_agent, + trusted_root_certificates, + maximum_response_bytes, + max_metadata_bytes, + ) +} + +#[allow(clippy::too_many_arguments)] +pub fn config_from_parts_with_authorization_durations( + base_url: &str, + trusted_jwks: &Value, + revoked_key_ids: Vec, + token: &Value, + authorization: Option<&Value>, + request_timeout: Option, + connect_timeout: Option, + user_agent: Option, + trusted_root_certificates: Option>, + maximum_response_bytes: Option, max_metadata_bytes: Option, ) -> Result { let base_url = parse_url(base_url, "`base_url`").map_err(ConfigError::Shape)?; @@ -908,14 +946,10 @@ pub fn config_from_parts_with_authorization( let mut config = EvidenceClientConfig::new(base_url, token_provider, trusted_jwks, revoked_key_ids); - if let Some(seconds) = request_timeout_seconds { - let timeout = duration_from_seconds(seconds, "`request_timeout_seconds`") - .map_err(ConfigError::Shape)?; - config = config.with_request_timeout(timeout); + if let Some(timeout) = request_timeout { + config = config.with_attempt_timeout(timeout); } - if let Some(seconds) = connect_timeout_seconds { - let timeout = duration_from_seconds(seconds, "`connect_timeout_seconds`") - .map_err(ConfigError::Shape)?; + if let Some(timeout) = connect_timeout { config = config.with_connect_timeout(timeout); } if let Some(user_agent) = user_agent { @@ -924,8 +958,8 @@ pub fn config_from_parts_with_authorization( if let Some(pem_bundle) = trusted_root_certificates { config = config.with_trusted_root_certificates(pem_bundle); } - if let Some(max_bytes) = max_response_bytes { - config = config.with_max_response_bytes(max_bytes); + if let Some(maximum_bytes) = maximum_response_bytes { + config = config.with_maximum_response_bytes(maximum_bytes); } if let Some(max_bytes) = max_metadata_bytes { config = config.with_max_metadata_bytes(max_bytes); diff --git a/crates/registry-evidence-client-py/src/lib.rs b/crates/registry-evidence-client-py/src/lib.rs index fed2abf094..b12f78e09a 100644 --- a/crates/registry-evidence-client-py/src/lib.rs +++ b/crates/registry-evidence-client-py/src/lib.rs @@ -6,13 +6,13 @@ //! current-thread tokio runtime and blocks on it for every network call, //! releasing the GIL for the duration so other Python threads keep running. -use std::collections::BTreeMap; +use std::{collections::BTreeMap, time::Duration}; use pyo3::exceptions::{PyException, PyRuntimeError, PyValueError}; use pyo3::prelude::*; #[cfg(test)] use pyo3::types::PyMapping; -use pyo3::types::{PyDict, PyList, PyTuple}; +use pyo3::types::{PyDict, PyFloat, PyInt, PyList, PyTuple}; // The wrapped SDK crate is `registry-evidence-client`, but this crate's own // `[lib] name` (the Python module name the spec requires) is also @@ -40,10 +40,11 @@ use evidence_client_sdk::VerifiedEvidenceRequestBatchItem as RealVerifiedEvidenc mod convert; use convert::{ - batch_spec_from_json, config_from_parts_with_authorization, datetime_from_unix_seconds, - evidence_to_json, exchange_from_authorization_json, json_to_python, map_client_error, - map_config_error, map_conversion_error, progressive_subjects_from_json, python_to_json, - selector_values_from_json, spec_from_json, subject_expectations_to_json, MappedError, + batch_spec_from_json, config_from_parts_with_authorization_durations, + datetime_from_unix_seconds, evidence_to_json, exchange_from_authorization_json, json_to_python, + map_client_error, map_config_error, map_conversion_error, progressive_subjects_from_json, + python_to_json, selector_values_from_json, spec_from_json, subject_expectations_to_json, + ConversionError, MappedError, }; use registry_platform_crypto::PrivateJwk; @@ -194,6 +195,50 @@ fn serialization_error(what: &str, error: serde_json::Error) -> PyErr { PyValueError::new_err(format!("{what} could not be described: {error}")) } +fn configuration_duration(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { + let invalid = || { + to_py_err( + py, + &map_conversion_error(&ConversionError::new( + "client timeouts must be finite non-negative seconds", + )), + ) + }; + if value.is_exact_instance_of::() { + return value + .extract::() + .ok() + .and_then(|seconds| Duration::try_from_secs_f64(seconds).ok()) + .ok_or_else(invalid); + } + if value.is_exact_instance_of::() { + return value + .extract::() + .map(Duration::from_secs) + .map_err(|_| invalid()); + } + Err(invalid()) +} + +fn configuration_u64( + py: Python<'_>, + value: &Bound<'_, PyAny>, + name: &'static str, +) -> PyResult { + let invalid = || { + to_py_err( + py, + &map_conversion_error(&ConversionError::new(format!( + "{name} must be a non-negative integer" + ))), + ) + }; + if !value.is_exact_instance_of::() { + return Err(invalid()); + } + value.extract::().map_err(|_| invalid()) +} + /// One request, closed and nonce-bearing, before any byte has left the /// process. /// @@ -833,7 +878,7 @@ impl EvidenceClient { /// /// `authorization={"exchange": ...}` is an alternative to `token` for /// a context-bound staff exchange. Exactly one must be configured. - /// `max_response_bytes` bounds the signed response `send` reads. + /// `maximum_response_bytes` bounds the signed response `send` reads. /// `max_metadata_bytes` bounds the documents `discover` and `fetch_jwks` /// read, which are neither signed nor verified, and is a separate decision. #[new] @@ -842,11 +887,11 @@ impl EvidenceClient { trusted_jwks, revoked_key_ids, token=None, - request_timeout_seconds=None, + attempt_timeout_seconds=None, connect_timeout_seconds=None, user_agent=None, trusted_root_certificates=None, - max_response_bytes=None, + maximum_response_bytes=None, max_metadata_bytes=None, authorization=None, ))] @@ -857,12 +902,12 @@ impl EvidenceClient { trusted_jwks: &Bound<'_, PyAny>, revoked_key_ids: Vec, token: Option<&Bound<'_, PyAny>>, - request_timeout_seconds: Option, - connect_timeout_seconds: Option, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, + connect_timeout_seconds: Option<&Bound<'_, PyAny>>, user_agent: Option, trusted_root_certificates: Option>, - max_response_bytes: Option, - max_metadata_bytes: Option, + maximum_response_bytes: Option<&Bound<'_, PyAny>>, + max_metadata_bytes: Option<&Bound<'_, PyAny>>, authorization: Option<&Bound<'_, PyAny>>, ) -> PyResult { let trusted_jwks_json = python_to_json(trusted_jwks) @@ -876,18 +921,26 @@ impl EvidenceClient { .map(python_to_json) .transpose() .map_err(|error| to_py_err(py, &map_conversion_error(&error)))?; - let config = config_from_parts_with_authorization( + let config = config_from_parts_with_authorization_durations( base_url, &trusted_jwks_json, revoked_key_ids, &token_json, authorization_json.as_ref(), - request_timeout_seconds, - connect_timeout_seconds, + attempt_timeout_seconds + .map(|value| configuration_duration(py, value)) + .transpose()?, + connect_timeout_seconds + .map(|value| configuration_duration(py, value)) + .transpose()?, user_agent, trusted_root_certificates, - max_response_bytes, - max_metadata_bytes, + maximum_response_bytes + .map(|value| configuration_u64(py, value, "maximum_response_bytes")) + .transpose()?, + max_metadata_bytes + .map(|value| configuration_u64(py, value, "max_metadata_bytes")) + .transpose()?, ) .map_err(|error| to_py_err(py, &map_config_error(&error)))?; let inner = py diff --git a/crates/registry-evidence-client-py/tests/python/test_construction.py b/crates/registry-evidence-client-py/tests/python/test_construction.py index 334a434902..46965ab991 100644 --- a/crates/registry-evidence-client-py/tests/python/test_construction.py +++ b/crates/registry-evidence-client-py/tests/python/test_construction.py @@ -97,10 +97,59 @@ def test_a_loopback_http_base_url_is_accepted(self): # the specific rules, not "any base URL fails". Port 1 is never # connected to here; construction never performs I/O. client = revc.EvidenceClient( - "http://127.0.0.1:1", fixtures.VALID_JWKS, [], "test-token" + "http://127.0.0.1:1", + fixtures.VALID_JWKS, + [], + "test-token", + attempt_timeout_seconds=5, + connect_timeout_seconds=2, ) self.assertIsInstance(client, revc.EvidenceClient) + def test_none_means_omission_for_optional_configuration(self): + client = revc.EvidenceClient( + "https://example.org", + fixtures.VALID_JWKS, + [], + "test-token", + attempt_timeout_seconds=None, + connect_timeout_seconds=None, + user_agent=None, + trusted_root_certificates=None, + maximum_response_bytes=None, + max_metadata_bytes=None, + authorization=None, + ) + self.assertIsInstance(client, revc.EvidenceClient) + + def test_numeric_configuration_rejects_booleans_and_overflow(self): + for options in ( + {"attempt_timeout_seconds": True}, + {"connect_timeout_seconds": False}, + {"maximum_response_bytes": True}, + {"maximum_response_bytes": 2**64}, + {"max_metadata_bytes": True}, + {"max_metadata_bytes": 2**64}, + ): + with self.subTest(options=options), self.assertRaises(revc.ConfigurationError): + revc.EvidenceClient( + "https://example.org", + fixtures.VALID_JWKS, + [], + "test-token", + **options, + ) + + def test_retired_top_level_timeout_name_is_rejected(self): + with self.assertRaises(TypeError): + revc.EvidenceClient( + "https://example.org", + fixtures.VALID_JWKS, + [], + "test-token", + request_timeout_seconds=30, + ) + def test_every_exception_carries_every_stable_attribute(self): with self.assertRaises(revc.EvidenceClientError) as raised: revc.EvidenceClient("http://example.org", fixtures.VALID_JWKS, [], "test-token") diff --git a/crates/registry-evidence-client-py/tests/python/test_discovery.py b/crates/registry-evidence-client-py/tests/python/test_discovery.py index 3f8e3cccac..87fdafe191 100644 --- a/crates/registry-evidence-client-py/tests/python/test_discovery.py +++ b/crates/registry-evidence-client-py/tests/python/test_discovery.py @@ -113,13 +113,13 @@ def test_discover_returns_the_definitions_document_as_a_dict(self): def test_the_metadata_bound_governs_discovery_and_the_response_bound_does_not(self): """The two bounds answer different questions. - `max_response_bytes` is derived from what the verifier accepts as a + `maximum_response_bytes` is derived from what the verifier accepts as a signed response, and the discovery document is neither signed nor verified, so tightening one must not silently disable the other endpoint. """ body = self._serve_definitions() - document = self._client(max_response_bytes=1).discover() + document = self._client(maximum_response_bytes=1).discover() self.assertEqual(document, DEFINITIONS_DOCUMENT) with self.assertRaises(revc.TransportError) as raised: diff --git a/crates/registry-evidence-client-py/tests/python/test_errors.py b/crates/registry-evidence-client-py/tests/python/test_errors.py index a3246e1d45..ceb3302299 100644 --- a/crates/registry-evidence-client-py/tests/python/test_errors.py +++ b/crates/registry-evidence-client-py/tests/python/test_errors.py @@ -129,7 +129,7 @@ def test_an_oversized_response_maps_to_transport(self): body=b"x" * 64, ) with self.assertRaises(revc.TransportError) as raised: - self._send(self._client(max_response_bytes=16)) + self._send(self._client(maximum_response_bytes=16)) error = raised.exception self.assertEqual(error.kind, "transport") self.assertEqual(error.transport_kind, "response-too-large") diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index 6f08bb9995..16c9346d38 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -788,7 +788,7 @@ impl EvidenceClient { .await .map_err(|_| metadata_protocol_failure())?; let mut request = validated - .immediate_get_with_timeout(self.config.request_timeout) + .immediate_get_with_timeout(self.config.attempt_timeout) .map_err(|_| metadata_protocol_failure())? .header(ACCEPT, media_type); if let Some(etag) = previous.and_then(|(_, etag, _)| etag) { @@ -1053,11 +1053,11 @@ impl EvidenceClient { fn response_bound(&self, format: EvidenceResponseFormat) -> u64 { match format { EvidenceResponseFormat::SignedJws | EvidenceResponseFormat::SdJwtVc => { - self.config.max_response_bytes + self.config.maximum_response_bytes } EvidenceResponseFormat::SdJwtVcBatch => self .config - .max_response_bytes + .maximum_response_bytes .min(MAX_SD_JWT_VC_BATCH_RESPONSE_BYTES as u64), } } @@ -1085,7 +1085,7 @@ impl EvidenceClient { response, EVIDENCE_REQUEST_BATCH_MEDIA_TYPE, self.config - .max_response_bytes + .maximum_response_bytes .min(MAX_EVIDENCE_REQUEST_BATCH_RESPONSE_BYTES as u64), ) .await?; @@ -1716,7 +1716,7 @@ fn response_trace_id(headers: &HeaderMap) -> Option { /// Build the outbound client from the pinned deployment options. fn build_client(config: &EvidenceClientConfig) -> Result { outbound::build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config @@ -2475,8 +2475,9 @@ mod tests { let fixture = signed_evidence(); let server = MockServer::start().await; let client = EvidenceClient::new( - config_for(&server.uri(), &fixture) - .with_max_response_bytes((MAX_EVIDENCE_REQUEST_BATCH_RESPONSE_BYTES * 2) as u64), + config_for(&server.uri(), &fixture).with_maximum_response_bytes( + (MAX_EVIDENCE_REQUEST_BATCH_RESPONSE_BYTES * 2) as u64, + ), ) .expect("the client is configured"); let prepared = client @@ -2741,7 +2742,7 @@ mod tests { /// The four ways a bounded read can fail are four different things to tell an /// adopter. A timeout while the body streams is the likely one, because the - /// request timeout runs until the body finishes, and reporting it as an + /// attempt timeout runs until the body finishes, and reporting it as an /// oversized response would send the adopter to the wrong place. #[tokio::test] async fn a_failed_body_read_reports_its_own_cause() { @@ -2771,14 +2772,14 @@ mod tests { .mount(&server) .await; let http = build_client( - &config_for(&server.uri(), &fixture).with_request_timeout(Duration::from_millis(100)), + &config_for(&server.uri(), &fixture).with_attempt_timeout(Duration::from_millis(100)), ) .expect("the outbound client builds"); let timeout = http .get(server.uri()) .send() .await - .expect_err("the request timeout elapses"); + .expect_err("the attempt timeout elapses"); assert!(timeout.is_timeout(), "{timeout:?}"); assert_eq!( read_failure_kind(&BoundedReadError::Transport(timeout)), @@ -2854,7 +2855,7 @@ mod tests { let server = MockServer::start().await; let client = discovery_client_with(&server, &fixture, document.clone(), |config| { - config.with_max_response_bytes(1) + config.with_maximum_response_bytes(1) }) .await; let read = client @@ -3020,9 +3021,10 @@ mod tests { async fn a_refusal_whose_body_cannot_be_read_keeps_its_status_and_identifier() { let fixture = signed_evidence(); let server = MockServer::start().await; - let client = - EvidenceClient::new(config_for(&server.uri(), &fixture).with_max_response_bytes(32)) - .expect("the client is configured"); + let client = EvidenceClient::new( + config_for(&server.uri(), &fixture).with_maximum_response_bytes(32), + ) + .expect("the client is configured"); let prepared = client .prepare(spec(SubjectExpectations::AcceptFirstUse)) .expect("the specification is accepted"); @@ -3057,9 +3059,10 @@ mod tests { async fn a_successful_answer_whose_body_cannot_be_read_is_a_transport_failure() { let fixture = signed_evidence(); let server = MockServer::start().await; - let client = - EvidenceClient::new(config_for(&server.uri(), &fixture).with_max_response_bytes(32)) - .expect("the client is configured"); + let client = EvidenceClient::new( + config_for(&server.uri(), &fixture).with_maximum_response_bytes(32), + ) + .expect("the client is configured"); let prepared = client .prepare(spec(SubjectExpectations::AcceptFirstUse)) .expect("the specification is accepted"); @@ -3104,7 +3107,7 @@ mod tests { /// The configured total timeout is the relying party's own bound on how long /// a decision may wait. #[tokio::test] - async fn an_elapsed_request_timeout_reports_a_timeout() { + async fn an_elapsed_attempt_timeout_reports_a_timeout() { let fixture = signed_evidence(); let server = MockServer::start().await; Mock::given(method("POST")) @@ -3117,7 +3120,7 @@ mod tests { .mount(&server) .await; let client = EvidenceClient::new( - config_for(&server.uri(), &fixture).with_request_timeout(Duration::from_millis(100)), + config_for(&server.uri(), &fixture).with_attempt_timeout(Duration::from_millis(100)), ) .expect("the client is configured"); let prepared = client diff --git a/crates/registry-evidence-client/src/config.rs b/crates/registry-evidence-client/src/config.rs index 67be06d352..afd3f796ae 100644 --- a/crates/registry-evidence-client/src/config.rs +++ b/crates/registry-evidence-client/src/config.rs @@ -25,12 +25,12 @@ use crate::{ /// The verifier refuses a signed response larger than 256 KiB, so a bigger /// body could never verify and reading it would only waste the relying party's /// memory. -pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 256 * 1024; +pub const DEFAULT_MAXIMUM_RESPONSE_BYTES: u64 = 256 * 1024; /// Longest deployment metadata document the client will read: the discovery /// document, and the published key set. /// -/// This is a separate decision from [`DEFAULT_MAX_RESPONSE_BYTES`], which is +/// This is a separate decision from [`DEFAULT_MAXIMUM_RESPONSE_BYTES`], which is /// derived from what the verifier will accept as a signed response. Neither /// document is signed and neither is verified, so that reasoning does not reach /// them, and a relying party that tightens one bound to what its own assertions @@ -42,8 +42,8 @@ pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 256 * 1024; /// publishes more. pub const DEFAULT_MAX_METADATA_BYTES: u64 = 256 * 1024; -/// Total time allowed for one exchange, including reading the response body. -pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// Total time allowed for one outbound attempt, including reading the response body. +pub const DEFAULT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(30); /// Time allowed for connection setup, including TLS negotiation. pub const DEFAULT_CONNECT_TIMEOUT: Duration = DEFAULT_OUTBOUND_CONNECT_TIMEOUT; @@ -75,11 +75,11 @@ pub struct EvidenceClientConfig { pub(crate) trusted_jwks: JwksDocument, pub(crate) revoked_key_ids: Vec, verification: VerificationStance, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>>, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) max_metadata_bytes: u64, } @@ -92,11 +92,11 @@ impl EvidenceClientConfig { trusted_jwks: JwksDocument { keys: Vec::new() }, revoked_key_ids: Vec::new(), verification: VerificationStance::Progressive, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_ATTEMPT_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, user_agent: None, trusted_root_certificates: None, - max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, max_metadata_bytes: DEFAULT_MAX_METADATA_BYTES, }) } @@ -120,11 +120,11 @@ impl EvidenceClientConfig { trusted_jwks, revoked_key_ids, verification: VerificationStance::Pinned, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_ATTEMPT_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, user_agent: None, trusted_root_certificates: None, - max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, max_metadata_bytes: DEFAULT_MAX_METADATA_BYTES, } } @@ -152,18 +152,18 @@ impl EvidenceClientConfig { trusted_jwks: JwksDocument { keys: Vec::new() }, revoked_key_ids: Vec::new(), verification: VerificationStance::Declined, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_ATTEMPT_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, user_agent: None, trusted_root_certificates: None, - max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, max_metadata_bytes: DEFAULT_MAX_METADATA_BYTES, } } #[must_use] - pub fn with_request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; + pub fn with_attempt_timeout(mut self, timeout: Duration) -> Self { + self.attempt_timeout = timeout; self } @@ -190,8 +190,8 @@ impl EvidenceClientConfig { /// Bound the signed response body, which only [`crate::EvidenceClient::send`] /// reads. #[must_use] - pub fn with_max_response_bytes(mut self, max_response_bytes: u64) -> Self { - self.max_response_bytes = max_response_bytes; + pub fn with_maximum_response_bytes(mut self, maximum_response_bytes: u64) -> Self { + self.maximum_response_bytes = maximum_response_bytes; self } @@ -233,8 +233,8 @@ impl EvidenceClientConfig { } #[must_use] - pub fn max_response_bytes(&self) -> u64 { - self.max_response_bytes + pub fn maximum_response_bytes(&self) -> u64 { + self.maximum_response_bytes } #[must_use] @@ -313,12 +313,12 @@ impl EvidenceClientConfig { } } } - if self.max_response_bytes == 0 || self.max_metadata_bytes == 0 { + if self.maximum_response_bytes == 0 || self.max_metadata_bytes == 0 { return Err(EvidenceClientError::configuration( "the response bounds must allow at least one byte", )); } - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(EvidenceClientError::configuration( "the timeouts must be greater than zero", )); @@ -336,10 +336,10 @@ impl fmt::Debug for EvidenceClientConfig { .debug_struct("EvidenceClientConfig") .field("base_url", &base_url_without_userinfo(&self.base_url)) .field("verifies", &self.verifies()) - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) .field("user_agent", &self.user_agent) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("max_metadata_bytes", &self.max_metadata_bytes) .finish_non_exhaustive() } @@ -564,8 +564,12 @@ mod tests { #[test] fn unusable_bounds_are_refused() { + assert_eq!( + config("https://evidence.example.org").maximum_response_bytes(), + DEFAULT_MAXIMUM_RESPONSE_BYTES + ); assert!(config("https://evidence.example.org") - .with_max_response_bytes(0) + .with_maximum_response_bytes(0) .validate() .is_err()); assert!(config("https://evidence.example.org") @@ -573,7 +577,7 @@ mod tests { .validate() .is_err()); assert!(config("https://evidence.example.org") - .with_request_timeout(Duration::ZERO) + .with_attempt_timeout(Duration::ZERO) .validate() .is_err()); assert!(config("https://evidence.example.org") @@ -661,7 +665,7 @@ mod tests { ); let failure = non_verifying_config("https://evidence.example.org") - .with_request_timeout(Duration::ZERO) + .with_attempt_timeout(Duration::ZERO) .validate() .expect_err("the timeouts still decide"); assert!( diff --git a/crates/registry-evidence-client/src/lib.rs b/crates/registry-evidence-client/src/lib.rs index b1c158bb83..a9cbeb01dd 100644 --- a/crates/registry-evidence-client/src/lib.rs +++ b/crates/registry-evidence-client/src/lib.rs @@ -187,8 +187,8 @@ pub use client::{ EvidenceClient, NonVerifyingEvidenceClient, RawEvidenceResponse, VerifiedEvidence, }; pub use config::{ - EvidenceClientConfig, DEFAULT_CONNECT_TIMEOUT, DEFAULT_MAX_RESPONSE_BYTES, - DEFAULT_REQUEST_TIMEOUT, + EvidenceClientConfig, DEFAULT_ATTEMPT_TIMEOUT, DEFAULT_CONNECT_TIMEOUT, + DEFAULT_MAXIMUM_RESPONSE_BYTES, }; pub use definitions::{ ConceptForm, DefinitionCardinality, DefinitionConcept, DefinitionKind, diff --git a/crates/registry-evidence-client/tests/against_a_real_deployment.rs b/crates/registry-evidence-client/tests/against_a_real_deployment.rs index 9a45e5d133..3580c3836b 100644 --- a/crates/registry-evidence-client/tests/against_a_real_deployment.rs +++ b/crates/registry-evidence-client/tests/against_a_real_deployment.rs @@ -1414,8 +1414,8 @@ impl Deployment { } /// The same client under a smaller response bound. - fn bounded_client(&self, access_token: &str, max_response_bytes: u64) -> EvidenceClient { - self.build_client(access_token, Some(max_response_bytes)) + fn bounded_client(&self, access_token: &str, maximum_response_bytes: u64) -> EvidenceClient { + self.build_client(access_token, Some(maximum_response_bytes)) } /// A client that acquires its credential from a provider rather than @@ -1430,15 +1430,19 @@ impl Deployment { .expect("the client configuration is usable") } - fn build_client(&self, access_token: &str, max_response_bytes: Option) -> EvidenceClient { + fn build_client( + &self, + access_token: &str, + maximum_response_bytes: Option, + ) -> EvidenceClient { let mut config = EvidenceClientConfig::new( self.base_url.clone(), Arc::new(StaticToken::new(access_token).expect("the credential is header-safe")), self.runtime.jwks().clone(), Vec::new(), ); - if let Some(max_response_bytes) = max_response_bytes { - config = config.with_max_response_bytes(max_response_bytes); + if let Some(maximum_response_bytes) = maximum_response_bytes { + config = config.with_maximum_response_bytes(maximum_response_bytes); } EvidenceClient::new(config).expect("the client configuration is usable") } diff --git a/crates/registry-evidencectl/src/evidence_binary.rs b/crates/registry-evidencectl/src/evidence_binary.rs index c6d9f861ea..e21eb323bd 100644 --- a/crates/registry-evidencectl/src/evidence_binary.rs +++ b/crates/registry-evidencectl/src/evidence_binary.rs @@ -3,6 +3,7 @@ use std::{ env, + ffi::OsStr, fs::{self, File}, io::{Read as _, Seek as _}, path::{Path, PathBuf}, @@ -53,6 +54,23 @@ const MAX_VERSION_OUTPUT_BYTES: u64 = 64 * 1024; /// How often a delegated run is checked while it is still running. const POLL_INTERVAL: Duration = Duration::from_millis(10); +#[derive(Clone, Copy)] +pub(crate) struct HelperSpec { + pub(crate) binary: &'static str, + pub(crate) component: &'static str, + pub(crate) explicit_flag: &'static str, + pub(crate) explicit_env: &'static str, + pub(crate) managed_env: &'static str, +} + +const EVIDENCE_HELPER: HelperSpec = HelperSpec { + binary: "evidence", + component: "evidence", + explicit_flag: "--evidence-bin", + explicit_env: "EVIDENCE_BIN", + managed_env: "RSTACK_EVIDENCE_BIN", +}; + #[derive(Debug)] pub(crate) struct DelegatedRunBoundError { message: String, @@ -75,32 +93,167 @@ impl DelegatedRunBoundError { } } -/// Resolve an explicit binary, `EVIDENCE_BIN`, or the first executable on -/// `PATH`, in that order. +/// Resolve the selected-generation Evidence runtime for an rstack invocation, +/// or an explicit binary, `EVIDENCE_BIN`, or `PATH` outside rstack. pub(crate) fn resolve(explicit: Option<&Path>) -> Result { + resolve_with_environment( + explicit, + env::var_os(EVIDENCE_HELPER.explicit_env).as_deref(), + env::var_os("RSTACK_GENERATION_DIR").as_deref(), + env::var_os(EVIDENCE_HELPER.managed_env).as_deref(), + env::var_os("RSTACK_RELEASE_TAG").as_deref(), + env::var_os("PATH").as_deref(), + ) +} + +pub(crate) fn resolve_with_environment( + explicit: Option<&Path>, + explicit_env: Option<&OsStr>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, + path: Option<&OsStr>, +) -> Result { + resolve_helper_with_environment( + EVIDENCE_HELPER, + explicit, + explicit_env, + generation_dir, + managed_path, + release_tag, + path, + ) +} + +pub(crate) fn resolve_helper_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + explicit_env: Option<&OsStr>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, + path: Option<&OsStr>, +) -> Result { + if let Some(generation_dir) = generation_dir { + return resolve_managed_helper( + spec, + explicit, + explicit_env, + Path::new(generation_dir), + managed_path, + release_tag, + ); + } + resolve_unmanaged_helper(spec, explicit, explicit_env, path) +} + +fn resolve_unmanaged_helper( + spec: HelperSpec, + explicit: Option<&Path>, + explicit_env: Option<&OsStr>, + path: Option<&OsStr>, +) -> Result { if let Some(path) = explicit { - if !path.is_file() { - return Err(operational_error(format!( - "evidence binary not found at {}", - path.display() - ))); - } + require_file(path, spec.binary, None)?; return Ok(path.to_path_buf()); } - if let Ok(env_path) = env::var("EVIDENCE_BIN") { - let path = PathBuf::from(&env_path); - if !path.is_file() { - return Err(operational_error(format!( - "evidence binary not found at {} (from EVIDENCE_BIN)", - path.display() + if let Some(env_path) = explicit_env { + let path = PathBuf::from(env_path); + require_file(&path, spec.binary, Some(spec.explicit_env))?; + return Ok(path); + } + find_on_path_value(spec.binary, path).ok_or_else(|| { + operational_error(format!( + "{} binary not found: pass {}, set {}, or add `{}` to PATH", + spec.binary, spec.explicit_flag, spec.explicit_env, spec.binary + )) + }) +} + +fn resolve_managed_helper( + spec: HelperSpec, + explicit: Option<&Path>, + explicit_env: Option<&OsStr>, + generation_dir: &Path, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result { + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(generation_dir).map_err(|error| { + operational_invalid_data(format!( + "rstack {release} selected generation {} cannot be resolved: {error}", + generation_dir.display() + )) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let managed_path = managed_path.ok_or_else(|| { + operational_invalid_data(format!( + "rstack {release} did not provide {} for its selected generation; install or repair component {} with rstack before retrying, evidencectl will not install helpers automatically", + spec.managed_env, spec.component + )) + })?; + let selected = canonical_helper(Path::new(managed_path), spec, Some(spec.managed_env))?; + let layout = canonical_helper(&layout_path, spec, Some("the selected generation"))?; + if selected != layout { + return Err(operational_invalid_data(format!( + "rstack {release} provided {} outside the selected generation helper path: {} (expected {})", + spec.managed_env, + selected.display(), + layout.display() + ))); + } + + for (override_path, source) in [ + (explicit, spec.explicit_flag), + (explicit_env.map(Path::new), spec.explicit_env), + ] { + let Some(override_path) = override_path else { + continue; + }; + let canonical = canonical_helper(override_path, spec, Some(source))?; + if canonical != selected { + return Err(operational_invalid_data(format!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected-generation identity", + source, + override_path.display(), + spec.binary, + selected.display() ))); } - return Ok(path); } - find_on_path("evidence").ok_or_else(|| { - operational_error( - "evidence binary not found: pass --evidence-bin, set EVIDENCE_BIN, or add `evidence` to PATH", - ) + Ok(selected) +} + +fn require_file(path: &Path, binary: &str, source: Option<&str>) -> Result<()> { + if path.is_file() { + return Ok(()); + } + let suffix = source + .map(|source| format!(" (from {source})")) + .unwrap_or_default(); + Err(operational_error(format!( + "{binary} binary not found at {}{suffix}", + path.display() + ))) +} + +fn canonical_helper(path: &Path, spec: HelperSpec, source: Option<&str>) -> Result { + require_file(path, spec.binary, source)?; + fs::canonicalize(path).map_err(|error| { + operational_invalid_data(format!( + "cannot resolve {} helper at {}{}: {error}", + spec.binary, + path.display(), + source + .map(|source| format!(" (from {source})")) + .unwrap_or_default() + )) }) } @@ -280,9 +433,8 @@ fn reported_version(stdout: &str) -> Option<&str> { (!version.is_empty()).then_some(version) } -fn find_on_path(name: &str) -> Option { - let path_var = env::var_os("PATH")?; - env::split_paths(&path_var).find_map(|dir| { +fn find_on_path_value(name: &str, path_var: Option<&OsStr>) -> Option { + env::split_paths(path_var?).find_map(|dir| { let candidate = dir.join(name); is_candidate_executable(&candidate).then_some(candidate) }) @@ -342,11 +494,18 @@ fn retry_busy_stub_while( #[cfg(test)] mod tests { - use std::{fs::OpenOptions, io::Write as _, os::unix::fs::OpenOptionsExt as _, path::Path}; + use std::{ + env, + ffi::OsStr, + fs::{self, OpenOptions}, + io::Write as _, + os::unix::fs::OpenOptionsExt as _, + path::Path, + }; use super::{ - ensure_matching_version_within, reported_version, retry_busy_stub, retry_busy_stub_while, - Duration, + ensure_matching_version_within, reported_version, resolve_with_environment, + retry_busy_stub, retry_busy_stub_while, Duration, }; fn write_stub(path: &Path, script: &str) { @@ -359,6 +518,137 @@ mod tests { file.write_all(script.as_bytes()).expect("write stub"); } + fn managed_evidence_paths(root: &Path) -> (std::path::PathBuf, std::path::PathBuf) { + let generation = root.join("selected-generation"); + let evidence = generation.join("components/evidence/evidence/evidence"); + fs::create_dir_all(evidence.parent().unwrap()).unwrap(); + write_stub( + &evidence, + &format!( + "#!/bin/sh\necho 'evidence {}'\n", + registry_platform_buildinfo::DISPLAY_VERSION + ), + ); + (generation, evidence) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_when_path_conflicts() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = managed_evidence_paths(root.path()); + let conflicting_directory = root.path().join("conflicting-path"); + fs::create_dir(&conflicting_directory).unwrap(); + write_stub( + &conflicting_directory.join("evidence"), + &format!( + "#!/bin/sh\necho 'evidence {}'\n", + registry_platform_buildinfo::DISPLAY_VERSION + ), + ); + let path = env::join_paths([conflicting_directory]).unwrap(); + + let resolved = resolve_with_environment( + None, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + Some(&path), + ) + .unwrap(); + + assert_eq!(resolved, fs::canonicalize(selected).unwrap()); + } + + #[test] + fn managed_resolution_refuses_an_explicit_same_version_binary_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = managed_evidence_paths(root.path()); + let override_path = root.path().join("same-version-evidence"); + write_stub( + &override_path, + &format!( + "#!/bin/sh\necho 'evidence {}'\n", + registry_platform_buildinfo::DISPLAY_VERSION + ), + ); + + let error = resolve_with_environment( + Some(&override_path), + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + None, + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + + assert!(diagnostic.contains("v-test"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + assert!(diagnostic.contains("--evidence-bin"), "{diagnostic}"); + assert!(diagnostic.contains("same-version-evidence"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_refuses_a_same_version_environment_override_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = managed_evidence_paths(root.path()); + let override_path = root.path().join("same-version-evidence"); + write_stub( + &override_path, + &format!( + "#!/bin/sh\necho 'evidence {}'\n", + registry_platform_buildinfo::DISPLAY_VERSION + ), + ); + + let error = resolve_with_environment( + None, + Some(override_path.as_os_str()), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + None, + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + + assert!(diagnostic.contains("EVIDENCE_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_does_not_fall_back_to_path_when_the_selected_helper_is_absent() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("selected-generation"); + fs::create_dir(&generation).unwrap(); + let path_directory = root.path().join("path"); + fs::create_dir(&path_directory).unwrap(); + write_stub( + &path_directory.join("evidence"), + &format!( + "#!/bin/sh\necho 'evidence {}'\n", + registry_platform_buildinfo::DISPLAY_VERSION + ), + ); + let path = env::join_paths([path_directory]).unwrap(); + + let error = resolve_with_environment( + None, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + Some(&path), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + + assert!(diagnostic.contains("RSTACK_EVIDENCE_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("will not install"), "{diagnostic}"); + } + /// A binary that never answers is exactly what the handshake exists to /// catch, so waiting for one forever would defeat it. #[test] diff --git a/crates/registry-evidencectl/src/lib.rs b/crates/registry-evidencectl/src/lib.rs index 5310278c76..40efc27fec 100644 --- a/crates/registry-evidencectl/src/lib.rs +++ b/crates/registry-evidencectl/src/lib.rs @@ -27,6 +27,7 @@ mod jwk; mod jwks; mod keygen; mod normalized; +mod oid4vci; mod project_snapshot; mod report; mod request; @@ -112,6 +113,8 @@ enum Command { Artifact(ArtifactCommand), /// Run the private local issuer and Registry Evidence pair. Dev(dev::DevArgs), + /// Operate the optional Evidence wallet-delivery service. + Oid4vci(oid4vci::Oid4vciArgs), /// Prepare a closed request for the active local project. #[command(subcommand)] Request(request::RequestCommand), @@ -438,6 +441,22 @@ fn run_entry() -> ExitCode { } else { OutputFormat::Human }; + if let Command::Oid4vci(args) = &cli.command { + let supplied = arguments + .iter() + .skip(1) + .take_while(|argument| *argument != "--") + .any(|argument| { + argument == "--format" + || argument + .to_str() + .is_some_and(|value| value.starts_with("--format=")) + }); + if supplied && !args.is_check() { + write_usage_failure(format); + return ExitCode::from(report::USAGE_EXIT); + } + } if format == OutputFormat::Json { if let Some(command) = human_only_command(&matches) { print_report(&unsupported_json_format_failure(&command)); @@ -558,6 +577,13 @@ fn run_entry() -> ExitCode { ) } Command::Dev(args) => safe_dev_command(dev::run_with_format(args, format)), + Command::Oid4vci(args) => safe_command( + oid4vci::run(args, format), + "evidence.oid4vci.failed", + "wallet-delivery helper".to_owned(), + "Evidence could not run the selected wallet-delivery command.", + "Install or repair the Evidence wallet-delivery capability and retry.", + ), Command::Request(command) => request::run(command), Command::Verify(args) => verify::run(args), Command::Audit(command) => audit_view::run(command, format), @@ -1254,6 +1280,26 @@ mod tests { use super::*; use clap::error::ErrorKind; + #[test] + fn wallet_delivery_has_a_named_product_subtree() { + let tree = command(); + let wallet = tree.find_subcommand("oid4vci").expect("oid4vci subtree"); + for action in ["check", "inspect", "openapi", "serve"] { + assert!(wallet.find_subcommand(action).is_some(), "missing {action}"); + } + assert!(command() + .try_get_matches_from([ + "evidencectl", + "oid4vci", + "check", + "--runtime-config", + "runtime.yaml", + "--format", + "json", + ]) + .is_ok()); + } + #[test] fn a_bounded_delegated_run_is_an_operational_failure() { let bound = anyhow::Error::new(evidence_binary::DelegatedRunBoundError::for_test()) @@ -1343,8 +1389,12 @@ mod tests { "source mock check", "target explain", "tooling editor", + "oid4vci check", ]; + /// Protocol and artifact output, or a foreground service without a report. + const NON_REPORT_COMMANDS: &[&str] = &["oid4vci inspect", "oid4vci openapi", "oid4vci serve"]; + fn leaf_paths(command: &clap::Command, prefix: &mut Vec, leaves: &mut Vec) { let subcommands: Vec<_> = command .get_subcommands() @@ -1386,8 +1436,8 @@ mod tests { assert_eq!(found, listed); } - /// Every command either reports JSON or is listed as human-only, so a - /// command added later cannot silently answer `--format json` with prose. + /// Every command declares its report or stream mode, so a command added + /// later cannot silently answer `--format json` with prose or an artifact. #[test] fn every_command_declares_whether_it_reports_json() { let mut leaves = Vec::new(); @@ -1396,6 +1446,7 @@ mod tests { .iter() .filter(|leaf| { !JSON_REPORTING_COMMANDS.contains(&leaf.as_str()) + && !NON_REPORT_COMMANDS.contains(&leaf.as_str()) && !HUMAN_ONLY_COMMANDS .iter() .any(|path| path.join(" ") == **leaf) diff --git a/crates/registry-evidencectl/src/oid4vci.rs b/crates/registry-evidencectl/src/oid4vci.rs new file mode 100644 index 0000000000..fd81ef295e --- /dev/null +++ b/crates/registry-evidencectl/src/oid4vci.rs @@ -0,0 +1,166 @@ +//! Named wallet-delivery commands backed by the selected Evidence helper. + +use std::os::unix::process::CommandExt; +use std::{ + env, + path::PathBuf, + process::{Command as ProcessCommand, ExitCode}, +}; + +use anyhow::Result; +use clap::{Args, Subcommand}; + +use crate::evidence_binary::{self, HelperSpec}; +use crate::OutputFormat; + +const HELPER: HelperSpec = HelperSpec { + binary: "evidence-oid4vci", + component: "evidence", + explicit_flag: "--oid4vci-bin", + explicit_env: "EVIDENCE_OID4VCI_BIN", + managed_env: "RSTACK_EVIDENCE_OID4VCI_BIN", +}; + +#[derive(Debug, Args)] +pub(crate) struct Oid4vciArgs { + /// Explicit wallet-delivery binary outside an rstack installation. + #[arg(long = "oid4vci-bin", hide = true)] + oid4vci_bin: Option, + #[command(subcommand)] + command: Oid4vciCommand, +} + +impl Oid4vciArgs { + pub(crate) fn is_check(&self) -> bool { + matches!(self.command, Oid4vciCommand::Check { .. }) + } +} + +#[derive(Debug, Subcommand)] +enum Oid4vciCommand { + /// Check the wallet-delivery runtime file offline. + Check { + /// Runtime configuration file to validate. + #[arg( + long = "runtime-config", + value_name = "FILE", + env = "EVIDENCE_OID4VCI_RUNTIME_CONFIG" + )] + config: PathBuf, + /// Exit 1 when a warning is reported. + #[arg(long)] + deny_warnings: bool, + /// Resolve runtime environment expressions and check their values. + #[arg(long)] + environment: bool, + }, + /// Print the derived protocol metadata for a deployment. + Inspect { + /// Runtime configuration file to inspect. + #[arg( + long = "runtime-config", + value_name = "FILE", + env = "EVIDENCE_OID4VCI_RUNTIME_CONFIG" + )] + config: PathBuf, + }, + /// Render the OpenAPI 3.1 document. + Openapi { + /// Write the document to this file. + #[arg(long)] + output: Option, + }, + /// Serve wallet-delivery endpoints until terminated. + Serve { + /// Runtime configuration file for the service. + #[arg( + long = "runtime-config", + value_name = "FILE", + env = "EVIDENCE_OID4VCI_RUNTIME_CONFIG" + )] + config: PathBuf, + }, +} + +pub(crate) fn run(args: Oid4vciArgs, format: OutputFormat) -> Result { + let binary = evidence_binary::resolve_helper_with_environment( + HELPER, + args.oid4vci_bin.as_deref(), + env::var_os(HELPER.explicit_env).as_deref(), + env::var_os("RSTACK_GENERATION_DIR").as_deref(), + env::var_os(HELPER.managed_env).as_deref(), + env::var_os("RSTACK_RELEASE_TAG").as_deref(), + env::var_os("PATH").as_deref(), + )?; + let mut child = ProcessCommand::new(binary); + match args.command { + Oid4vciCommand::Check { + config, + deny_warnings, + environment, + } => { + child.arg("check").arg("--runtime-config").arg(config); + child.arg("--format").arg(match format { + OutputFormat::Human => "human", + OutputFormat::Json => "json", + }); + if deny_warnings { + child.arg("--deny-warnings"); + } + if environment { + child.arg("--environment"); + } + } + Oid4vciCommand::Inspect { config } => { + child.arg("inspect").arg("--runtime-config").arg(config); + } + Oid4vciCommand::Openapi { output } => { + child.arg("openapi"); + if let Some(output) = output { + child.arg("--output").arg(output); + } + } + Oid4vciCommand::Serve { config } => { + child.arg("serve").arg("--runtime-config").arg(config); + } + } + let failure = child.exec(); + Err(anyhow::Error::new(failure).context("cannot launch selected wallet-delivery binary")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ffi::OsStr, fs}; + + #[test] + fn selected_wallet_helper_cannot_fall_back_to_path_or_wrong_override() { + let generation = tempfile::tempdir().unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + let resolved = evidence_binary::resolve_helper_with_environment( + HELPER, + None, + None, + Some(generation.path().as_os_str()), + None, + Some(OsStr::new("v0.44.0")), + Some(outside.path().parent().unwrap().as_os_str()), + ); + assert!(resolved.is_err()); + let selected = generation + .path() + .join("components/evidence/evidence-oid4vci/evidence-oid4vci"); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"fixture").unwrap(); + let mismatch = evidence_binary::resolve_helper_with_environment( + HELPER, + Some(outside.path()), + None, + Some(generation.path().as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v0.44.0")), + None, + ); + assert!(mismatch.is_err()); + } +} diff --git a/crates/registry-evidencectl/src/source_add.rs b/crates/registry-evidencectl/src/source_add.rs index 25c82a9cc0..5fd750ed3c 100644 --- a/crates/registry-evidencectl/src/source_add.rs +++ b/crates/registry-evidencectl/src/source_add.rs @@ -80,7 +80,7 @@ pub(crate) struct SourceAddArgs { /// Perform the reviewed connection; without it this command reviews new choices only. #[arg(long)] pub apply: bool, - /// Public Base Registry Engine tooling binary; otherwise BREGCTL_BIN or bregctl on PATH. + /// Public Base Registry Engine tooling binary; otherwise the rstack-selected helper, BREGCTL_BIN, or bregctl on PATH. #[arg(long)] pub bregctl_bin: Option, } @@ -143,11 +143,7 @@ struct RowScope { } pub(crate) fn run(args: SourceAddArgs, format: OutputFormat) -> Result { - let binary = args - .bregctl_bin - .clone() - .or_else(|| std::env::var_os("BREGCTL_BIN").map(PathBuf::from)) - .unwrap_or_else(|| PathBuf::from("bregctl")); + let binary = resolve_bregctl(args.bregctl_bin.as_deref())?; check_public_bregctl(&binary)?; // A JSON run writes one report and nothing else: it neither prompts nor // narrates the review on standard error. @@ -169,6 +165,44 @@ pub(crate) fn run(args: SourceAddArgs, format: OutputFormat) -> Result Ok(ExitCode::SUCCESS) } +const BREGCTL_HELPER: evidence_binary::HelperSpec = evidence_binary::HelperSpec { + binary: "bregctl", + component: "breg", + explicit_flag: "--bregctl-bin", + explicit_env: "BREGCTL_BIN", + managed_env: "RSTACK_BREGCTL_BIN", +}; + +fn resolve_bregctl(explicit: Option<&Path>) -> Result { + resolve_bregctl_with_environment( + explicit, + std::env::var_os(BREGCTL_HELPER.explicit_env).as_deref(), + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(BREGCTL_HELPER.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + std::env::var_os("PATH").as_deref(), + ) +} + +fn resolve_bregctl_with_environment( + explicit: Option<&Path>, + explicit_env: Option<&std::ffi::OsStr>, + generation_dir: Option<&std::ffi::OsStr>, + managed_path: Option<&std::ffi::OsStr>, + release_tag: Option<&std::ffi::OsStr>, + path: Option<&std::ffi::OsStr>, +) -> Result { + evidence_binary::resolve_helper_with_environment( + BREGCTL_HELPER, + explicit, + explicit_env, + generation_dir, + managed_path, + release_tag, + path, + ) +} + fn print_human_report(report: &Value) { println!( "Evidence source connection {}: {}", @@ -193,7 +227,7 @@ fn print_human_report(report: &Value) { fn check_public_bregctl(binary: &Path) -> Result<()> { let expected = format!("bregctl {}", registry_platform_buildinfo::DISPLAY_VERSION); let requirement = format!( - "source add drives public Base Registry Engine tooling and requires {expected} on PATH; set --bregctl-bin or BREGCTL_BIN to that binary" + "source add drives public Base Registry Engine tooling and requires {expected}; outside rstack, set --bregctl-bin or BREGCTL_BIN to that binary, or put it on PATH" ); let version = public_output(binary, &["--version".into()]).context(requirement.clone())?; let reported = std::str::from_utf8(&version).ok().map(str::trim); @@ -224,11 +258,11 @@ impl std::fmt::Display for BregctlVersionMismatch { match &self.found { Some(found) => write!( formatter, - "source add drives public Base Registry Engine tooling and found {found} on PATH; it requires {required}, set --bregctl-bin or BREGCTL_BIN to that binary" + "source add drives public Base Registry Engine tooling and found {found}; it requires {required}. Outside rstack, set --bregctl-bin or BREGCTL_BIN to that binary, or put it on PATH" ), None => write!( formatter, - "source add drives public Base Registry Engine tooling and requires {required} on PATH; set --bregctl-bin or BREGCTL_BIN to that binary" + "source add drives public Base Registry Engine tooling and requires {required}. Outside rstack, set --bregctl-bin or BREGCTL_BIN to that binary, or put it on PATH" ), } } @@ -2103,6 +2137,78 @@ assertionDefinitions: check_public_bregctl(&script("bregctl", &expected)).unwrap(); } + #[test] + fn managed_source_add_uses_selected_bregctl_when_path_conflicts() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("selected-generation"); + let selected = generation.join("components/breg/bregctl/bregctl"); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + let conflicting_directory = root.path().join("conflicting-path"); + fs::create_dir(&conflicting_directory).unwrap(); + let script = |path: &Path| { + fs::write( + path, + format!( + "#!/bin/sh\necho 'bregctl {}'\n", + registry_platform_buildinfo::DISPLAY_VERSION + ), + ) + .unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + }; + script(&selected); + script(&conflicting_directory.join("bregctl")); + let path = std::env::join_paths([conflicting_directory]).unwrap(); + + let resolved = resolve_bregctl_with_environment( + None, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(std::ffi::OsStr::new("v-test")), + Some(&path), + ) + .unwrap(); + + assert_eq!(resolved, fs::canonicalize(selected).unwrap()); + } + + #[test] + fn managed_source_add_refuses_same_version_bregctl_override_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("selected-generation"); + let selected = generation.join("components/breg/bregctl/bregctl"); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + let override_path = root.path().join("same-version-bregctl"); + for path in [&selected, &override_path] { + fs::write( + path, + format!( + "#!/bin/sh\necho 'bregctl {}'\n", + registry_platform_buildinfo::DISPLAY_VERSION + ), + ) + .unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + + let error = resolve_bregctl_with_environment( + Some(&override_path), + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(std::ffi::OsStr::new("v-test")), + None, + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + + assert!(diagnostic.contains("v-test"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + assert!(diagnostic.contains("--bregctl-bin"), "{diagnostic}"); + assert!(diagnostic.contains("same-version-bregctl"), "{diagnostic}"); + } + #[test] fn the_long_help_states_the_matching_bregctl_requirement() { let command = crate::command(); diff --git a/crates/registry-evidencectl/tests/oid4vci.rs b/crates/registry-evidencectl/tests/oid4vci.rs new file mode 100644 index 0000000000..e9b2bb0ee3 --- /dev/null +++ b/crates/registry-evidencectl/tests/oid4vci.rs @@ -0,0 +1,78 @@ +use std::{fs, os::unix::fs::PermissionsExt, process::Command}; + +fn wallet_fixture() -> (tempfile::TempDir, std::path::PathBuf) { + let generation = tempfile::tempdir().unwrap(); + let binary = generation + .path() + .join("components/evidence/evidence-oid4vci/evidence-oid4vci"); + fs::create_dir_all(binary.parent().unwrap()).unwrap(); + fs::write( + &binary, + b"#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s\\n' \"$@\"\n", + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o755)).unwrap(); + (generation, binary) +} + +#[test] +fn wallet_check_uses_selected_optional_binary_and_json_report_flag() { + let (generation, binary) = wallet_fixture(); + let output = Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .env("RSTACK_GENERATION_DIR", generation.path()) + .env("RSTACK_RELEASE_TAG", "v0.44.0") + .env("RSTACK_EVIDENCE_OID4VCI_BIN", binary) + .args([ + "--format", + "json", + "oid4vci", + "check", + "--runtime-config", + "runtime.yaml", + "--environment", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + assert_eq!( + output.stdout, + b"check\n--runtime-config\nruntime.yaml\n--format\njson\n--environment\n" + ); +} + +#[test] +fn wallet_artifact_and_service_refuse_report_format_before_exec() { + let (generation, binary) = wallet_fixture(); + for action in ["openapi", "serve"] { + let mut command = Command::new(env!("CARGO_BIN_EXE_evidencectl")); + command + .env("RSTACK_GENERATION_DIR", generation.path()) + .env("RSTACK_RELEASE_TAG", "v0.44.0") + .env("RSTACK_EVIDENCE_OID4VCI_BIN", &binary) + .args(["oid4vci", action, "--format", "human"]); + if action == "serve" { + command.args(["--runtime-config", "runtime.yaml"]); + } + let output = command.output().unwrap(); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + assert!(output.stdout.is_empty(), "{output:?}"); + assert!(!output.stderr.is_empty(), "{output:?}"); + } +} + +#[test] +fn wallet_helper_never_falls_back_to_path_in_managed_mode() { + let (generation, _binary) = wallet_fixture(); + let output = Command::new(env!("CARGO_BIN_EXE_evidencectl")) + .env("RSTACK_GENERATION_DIR", generation.path()) + .env("RSTACK_RELEASE_TAG", "v0.44.0") + .env_remove("RSTACK_EVIDENCE_OID4VCI_BIN") + .args(["oid4vci", "openapi"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(3), "{output:?}"); + assert!(output.stdout.is_empty(), "{output:?}"); + let diagnostic = String::from_utf8(output.stderr).unwrap(); + assert!(diagnostic.contains("rstack"), "{diagnostic}"); +} diff --git a/crates/registry-evidencectl/tests/source_add_cli.rs b/crates/registry-evidencectl/tests/source_add_cli.rs index a54a93e822..225dde5b0d 100644 --- a/crates/registry-evidencectl/tests/source_add_cli.rs +++ b/crates/registry-evidencectl/tests/source_add_cli.rs @@ -1,7 +1,7 @@ -//! `source add` composes a local Evidence source through the public -//! `bregctl` binary of the same version found on PATH. When that binary -//! answers with a different version, the refusal must be as legible in -//! `--format json` as it already is in the human renderer. +//! `source add` composes a local Evidence source through the public matching +//! `bregctl` selected for the invocation. When that binary answers with a +//! different version, the refusal must be as legible in `--format json` as it +//! already is in the human renderer. use std::{ fs, diff --git a/crates/registry-manifest-cli/Cargo.toml b/crates/registry-manifest-cli/Cargo.toml index 089a12e17e..5bfa9130fd 100644 --- a/crates/registry-manifest-cli/Cargo.toml +++ b/crates/registry-manifest-cli/Cargo.toml @@ -29,6 +29,7 @@ name = "manifest-schema" required-features = ["schema"] [dependencies] +clap.workspace = true registry-manifest-core = { workspace = true } registry-platform-buildinfo.workspace = true registry-platform-config.workspace = true diff --git a/crates/registry-manifest-cli/README.md b/crates/registry-manifest-cli/README.md index 6eb278731b..7e8073ff3a 100644 --- a/crates/registry-manifest-cli/README.md +++ b/crates/registry-manifest-cli/README.md @@ -17,24 +17,24 @@ cargo run -p registry-manifest-cli -- validate products/manifest/profiles/exampl Render one artifact: ```sh -cargo run -p registry-manifest-cli -- render products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --format catalog +cargo run -p registry-manifest-cli -- render products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --artifact-format catalog ``` Render a service-form JSON Schema: ```sh -cargo run -p registry-manifest-cli -- render products/manifest/fixtures/cpsv-ap/health-linked-child-support.metadata.yaml --format form-json-schema --form child-support-review-form +cargo run -p registry-manifest-cli -- render products/manifest/fixtures/cpsv-ap/health-linked-child-support.metadata.yaml --artifact-format form-json-schema --form child-support-review-form ``` Publish a static metadata directory: ```sh -cargo run -p registry-manifest-cli -- publish products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --out target/metadata/public +cargo run -p registry-manifest-cli -- publish products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --output target/metadata/public ``` By default, publishing writes every artifact, including `.well-known/api-catalog` and `.well-known/registry-manifest.json`, inside the directory passed to -`--out`. Serve that directory as `/`; `/metadata/index.json` is the canonical +`--output`. Serve that directory as `/`; `/metadata/index.json` is the canonical metadata entry point, `/.well-known/api-catalog` is the standards-facing discovery document, and `/.well-known/registry-manifest.json` remains for compatibility with older Registry Manifest clients. @@ -42,18 +42,22 @@ compatibility with older Registry Manifest clients. package-level digest, and SHA-256 digests for each indexed metadata artifact. If the metadata bundle is a sibling of the site root rather than the site root -itself (for example, when `--out` points at `/srv/site/metadata-public/`), pass +itself (for example, when `--output` points at `/srv/site/metadata-public/`), pass `--site-root` so the discovery files land at the URL root: ```sh cargo run -p registry-manifest-cli -- publish products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ - --out /srv/site/metadata-public \ + --output /srv/site/metadata-public \ --site-root /srv/site ``` With `--site-root SITE`, `.well-known/*` is written under `SITE/.well-known/` -while the metadata bundle still lands under `--out`. No files are written -outside `--out` (or `SITE`, when set). +while the metadata bundle still lands under `--output`. No files are written +outside `--output` (or `SITE`, when set). +Publishing reports in human-readable form by default. Pass `--format json` for +the versioned JSON report; artifact files still go only to `--output` and never +to standard output. The retired `--out` option is refused with `--output` named +as its replacement. Validate all checked-in profile descriptors and fixtures: @@ -65,6 +69,9 @@ cargo run -p registry-manifest-cli -- validate-profiles products/manifest/profil print every finding as `severity[code] file:line:column /pointer` with the fix. Both accept `--format json` and `--deny-warnings`, and exit 0 when nothing was refused, 1 on a refusal, 2 on a usage error, and 3 when a file cannot be read. +The retired `--json` flag is refused with `--format json` named as its +replacement. `render` writes exact artifact JSON to standard output, so it uses +`--artifact-format` and refuses report `--format`. Regenerate the editor schemas after changing a reader type: diff --git a/crates/registry-manifest-cli/src/lib.rs b/crates/registry-manifest-cli/src/lib.rs index 69feebcc55..128b0ff2ff 100644 --- a/crates/registry-manifest-cli/src/lib.rs +++ b/crates/registry-manifest-cli/src/lib.rs @@ -14,6 +14,7 @@ use std::fs; use std::io::{self, Read as _}; use std::path::Path; +use clap::{Arg, ArgAction, Command}; use registry_platform_yaml::{ Diagnostic, EnvelopeRule, FormatSpec, Report, Severity, Source, MAXIMUM_DOCUMENT_BYTES, }; @@ -50,6 +51,153 @@ pub const PROFILE_SCHEMA_VERSION: &str = "registry-manifest-profile/v1"; /// The file name of a profile descriptor inside its profile's directory. pub const PROFILE_FILE_NAME: &str = "profile.yaml"; +const ARTIFACT_FORMATS: [&str; 12] = [ + "catalog", + "evidence-offerings", + "evidence-offering", + "policies", + "policy", + "dcat", + "bregdcat-ap", + "cpsv-ap", + "shacl", + "json-schema", + "form-json-schema", + "ogc-records", +]; + +/// Registry Manifest's canonical public command tree. +/// +/// The private executable sets its displayed binary name to `registry-manifest`. +/// The unified launcher and generated catalog retain the public product name +/// `manifest`, so help and metadata do not expose a private entrypoint. +pub fn command() -> Command { + Command::new("manifest") + .about("Validate, render, and publish portable registry metadata") + .version(registry_platform_buildinfo::DISPLAY_VERSION) + .subcommand_required(true) + .arg_required_else_help(true) + .subcommand(check_command( + "validate", + "Validate one Registry Manifest metadata document", + Arg::new("metadata") + .value_name("FILE") + .help("Registry Manifest metadata document to validate") + .required(true), + )) + .subcommand(check_command( + "validate-profiles", + "Validate profile descriptors and their declared fixtures", + Arg::new("profiles") + .value_name("DIRECTORY") + .help("Directory containing Registry Manifest profiles") + .default_value("profiles"), + )) + .subcommand( + Command::new("render") + .about("Render one artifact from a Registry Manifest document") + .long_about( + "Render one artifact from a Registry Manifest document. The exact artifact \ + JSON bytes are written to standard output; report --format does not apply.", + ) + .arg( + Arg::new("metadata") + .value_name("FILE") + .help("Registry Manifest metadata document to render") + .required(true), + ) + .arg( + Arg::new("artifact-format") + .long("artifact-format") + .value_name("ARTIFACT") + .help("Artifact to render to standard output") + .value_parser(ARTIFACT_FORMATS) + .required(true), + ) + .arg( + Arg::new("profile") + .long("profile") + .value_name("ID") + .help("Declared DCAT application profile to render"), + ) + .arg( + Arg::new("dataset") + .long("dataset") + .value_name("ID") + .help("Dataset whose policy or entity schema to render"), + ) + .arg( + Arg::new("entity") + .long("entity") + .value_name("NAME") + .help("Entity whose JSON Schema to render"), + ) + .arg( + Arg::new("form") + .long("form") + .value_name("ID") + .help("Form whose JSON Schema to render"), + ) + .arg( + Arg::new("offering") + .long("offering") + .value_name("ID") + .help("Evidence offering document to render"), + ), + ) + .subcommand( + Command::new("publish") + .about("Publish a static Registry Manifest metadata bundle") + .long_about( + "Publish a static Registry Manifest metadata bundle beneath --output. The \ + command emits a separate human or JSON report and never writes artifact \ + bytes to standard output.", + ) + .arg( + Arg::new("metadata") + .value_name("FILE") + .help("Registry Manifest metadata document to publish") + .required(true), + ) + .arg( + Arg::new("output") + .long("output") + .value_name("DIRECTORY") + .help("Directory that receives the metadata bundle") + .default_value("public/metadata"), + ) + .arg( + Arg::new("site-root") + .long("site-root") + .value_name("DIRECTORY") + .help("Separate site root that receives .well-known discovery files"), + ) + .arg(report_format()), + ) +} + +fn check_command(name: &'static str, about: &'static str, path: Arg) -> Command { + Command::new(name) + .about(about) + .arg(path) + .arg(report_format()) + .arg( + Arg::new("deny-warnings") + .long("deny-warnings") + .help("Treat warnings as a domain refusal") + .action(ArgAction::SetTrue), + ) +} + +fn report_format() -> Arg { + Arg::new("format") + .long("format") + .value_name("FORMAT") + .help("Report format") + .value_parser(["human", "json"]) + .default_value("human") +} + /// A metadata manifest, as the shared reader reads it. pub const METADATA_FORMAT: FormatSpec<'static> = FormatSpec { kind: METADATA_KIND, diff --git a/crates/registry-manifest-cli/src/main.rs b/crates/registry-manifest-cli/src/main.rs index 93d3154871..f3f4af222d 100644 --- a/crates/registry-manifest-cli/src/main.rs +++ b/crates/registry-manifest-cli/src/main.rs @@ -1,13 +1,13 @@ // SPDX-License-Identifier: Apache-2.0 -use std::env; use std::fs; use std::path::{Component, Path, PathBuf}; use std::process::ExitCode; +use clap::{error::ErrorKind, ArgMatches}; use registry_manifest_cli::{ - check_metadata_file, check_profiles, manifest_digest, metadata_error_diagnostics, ReadManifest, - CTL_REPORT_API_VERSION, CTL_REPORT_KIND, + check_metadata_file, check_profiles, command, manifest_digest, metadata_error_diagnostics, + ReadManifest, CTL_REPORT_API_VERSION, CTL_REPORT_KIND, }; use registry_manifest_core::{ canonicalize_json, compile_manifest, render_base_dcat, render_breg_dcat_ap, render_catalog, @@ -29,7 +29,7 @@ const USAGE_ACTION: &str = arguments."; fn main() -> ExitCode { - let args = env::args().skip(1).collect::>(); + let args = std::env::args().skip(1).collect::>(); ExitCode::from(run(&args)) } @@ -55,36 +55,68 @@ impl From for Failure { } fn run(args: &[String]) -> u8 { - let command = args.first().map(String::as_str); - if matches!( - command, - Some("validate" | "validate-profiles" | "render" | "publish") - ) && args[1..].iter().any(|arg| arg == "--help" || arg == "-h") - { - println!("{}", usage()); - return 0; + if let Some(message) = migration_error(args) { + return finish(args, Err(Failure::Usage(message))); } - let result = match command { - Some("--version") => { - println!( - "registry-manifest {}", - registry_platform_buildinfo::DISPLAY_VERSION - ); - Ok(0) + let parsed = command() + .bin_name("registry-manifest") + .display_name("registry-manifest") + .try_get_matches_from( + std::iter::once("registry-manifest").chain(args.iter().map(String::as_str)), + ); + let matches = match parsed { + Ok(matches) => matches, + Err(error) + if matches!( + error.kind(), + ErrorKind::DisplayHelp | ErrorKind::DisplayVersion + ) => + { + print!("{error}"); + return 0; } - Some("--help" | "-h" | "help") => { - println!("{}", usage()); - Ok(0) + Err(_) => { + return finish( + args, + Err(Failure::Usage("the command arguments are invalid")), + ) } - Some("validate") => validate_command(&args[1..]), - Some("validate-profiles") => validate_profiles_command(&args[1..]), - Some("render") => render_command(&args[1..]).map(|()| 0), - Some("publish") => publish_command(&args[1..]).map(|()| 0), - Some(_) => Err(Failure::Usage( - "the command is not one registry-manifest runs", - )), - None => Err(Failure::Usage("registry-manifest needs a command")), }; + let result = match matches.subcommand() { + Some(("validate", matches)) => validate_command(matches), + Some(("validate-profiles", matches)) => validate_profiles_command(matches), + Some(("render", matches)) => render_command(matches).map(|()| 0), + Some(("publish", matches)) => publish_command(matches).map(|()| 0), + _ => Err(Failure::Usage("registry-manifest needs a command")), + }; + finish(args, result) +} + +fn migration_error(args: &[String]) -> Option<&'static str> { + let command = args.first().map(String::as_str); + let has_option = |name: &str| { + args.iter().skip(1).any(|argument| { + argument == name + || argument + .strip_prefix(name) + .is_some_and(|suffix| suffix.starts_with('=')) + }) + }; + match command { + Some("render") if has_option("--format") => { + Some("render --format is retired; use --artifact-format instead") + } + Some("publish") if has_option("--out") => { + Some("publish --out is retired; use --output instead") + } + Some("validate" | "validate-profiles" | "publish") if has_option("--json") => { + Some("--json is retired; use --format json instead") + } + _ => None, + } +} + +fn finish(args: &[String], result: Result) -> u8 { match result { Ok(exit) => exit, Err(Failure::Usage(message)) => { @@ -100,7 +132,18 @@ fn run(args: &[String]) -> u8 { USAGE_EXIT } Err(Failure::Refused(message)) => { - eprintln!("{message}"); + if machine_readable(args) { + let mut report = Report::new(vec![Diagnostic::error( + "manifest.operation.refused", + "", + &message, + "Correct the manifest or command inputs and retry.", + )]); + report.set_files_checked(1); + write_report(&CtlReport::new(command_name(args), 1, &report)); + } else { + eprintln!("{message}"); + } 1 } Err(Failure::Diagnostics { @@ -108,17 +151,25 @@ fn run(args: &[String]) -> u8 { exit, sentence, }) => { - eprintln!("{sentence}"); - eprint!("{}", report.render_human()); + if machine_readable(args) { + write_report(&CtlReport::new(command_name(args), exit, &report)); + } else { + eprintln!("{sentence}"); + eprint!("{}", report.render_human()); + } exit } } } +fn command_name(args: &[String]) -> &str { + args.first().map(String::as_str).unwrap_or("usage") +} + /// Whether a usage error is reported as JSON: `--format json` was asked of a /// command whose `--format` selects the report format. fn machine_readable(args: &[String]) -> bool { - !matches!(args.first().map(String::as_str), Some("render" | "publish")) + !matches!(args.first().map(String::as_str), Some("render")) && args.iter().enumerate().any(|(index, arg)| { arg == "--format=json" || (arg == "--format" && args.get(index + 1).map(String::as_str) == Some("json")) @@ -126,48 +177,22 @@ fn machine_readable(args: &[String]) -> bool { } /// The options `validate` and `validate-profiles` share. -struct CheckOptions { - path: Option, +struct CheckOptions<'a> { + path: &'a str, json: bool, deny_warnings: bool, } -impl CheckOptions { - fn parse(args: &[String]) -> Result { - let mut options = Self { - path: None, - json: false, - deny_warnings: false, - }; - let mut args = args.iter(); - while let Some(arg) = args.next() { - let format = match arg.as_str() { - "--deny-warnings" => { - options.deny_warnings = true; - continue; - } - "--format" => args.next().map(String::as_str), - other => match other.strip_prefix("--format=") { - Some(value) => Some(value), - None if other.starts_with('-') => { - return Err(Failure::Usage("an option is not one this command accepts")) - } - None if options.path.is_some() => { - return Err(Failure::Usage("this command takes one path")) - } - None => { - options.path = Some(other.to_owned()); - continue; - } - }, - }; - options.json = match format { - Some("json") => true, - Some("human") => false, - _ => return Err(Failure::Usage("--format accepts human or json")), - }; - } - Ok(options) +impl<'a> CheckOptions<'a> { + fn parse(matches: &'a ArgMatches, path: &str) -> Result { + Ok(Self { + path: matches + .get_one::(path) + .map(String::as_str) + .ok_or(Failure::Usage("the command needs an input path"))?, + json: matches.get_one::("format").map(String::as_str) == Some("json"), + deny_warnings: matches.get_flag("deny-warnings"), + }) } } @@ -199,6 +224,8 @@ struct CtlReport<'a> { source_manifest_digest: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] profiles: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output: Option, diagnostics: &'a [Diagnostic], } @@ -220,6 +247,7 @@ impl<'a> CtlReport<'a> { warnings: report.warning_count(), source_manifest_digest: None, profiles: None, + output: None, diagnostics: report.diagnostics(), } } @@ -233,12 +261,10 @@ fn write_report(report: &CtlReport<'_>) { } /// `validate`: check one metadata manifest offline (CFG-CHECK-1). -fn validate_command(args: &[String]) -> Result { - let options = CheckOptions::parse(args)?; - let path = options.path.ok_or(Failure::Usage( - "validate needs the path of a metadata manifest", - ))?; - let check = check_metadata_file(Path::new(&path)); +fn validate_command(matches: &ArgMatches) -> Result { + let options = CheckOptions::parse(matches, "metadata")?; + let path = options.path; + let check = check_metadata_file(Path::new(path)); let mut report = check.report; let digest = match check.manifest.as_ref().map(manifest_digest) { Some(Ok(digest)) => Some(digest), @@ -275,9 +301,9 @@ fn validate_command(args: &[String]) -> Result { /// `validate-profiles`: check every profile descriptor and the fixtures it /// lists offline (CFG-CHECK-1). -fn validate_profiles_command(args: &[String]) -> Result { - let options = CheckOptions::parse(args)?; - let root = PathBuf::from(options.path.as_deref().unwrap_or("profiles")); +fn validate_profiles_command(matches: &ArgMatches) -> Result { + let options = CheckOptions::parse(matches, "profiles")?; + let root = PathBuf::from(options.path); let check = check_profiles(&root); let exit = check_exit(&check.report, check.unavailable, options.deny_warnings); if options.json { @@ -361,34 +387,32 @@ fn compile(read: &ReadManifest, sentences: Sentences) -> Result Result<(), Failure> { - let manifest_path = args.first().ok_or(Failure::Usage( - "render needs the path of a metadata manifest", - ))?; - let format = option_value(args, "--format").ok_or(Failure::Usage( - "render needs --format and the artifact to render", - ))?; - let profile = option_value(args, "--profile"); +fn render_command(matches: &ArgMatches) -> Result<(), Failure> { + let manifest_path = match_value(matches, "metadata")?; + let format = match_value(matches, "artifact-format")?; + let profile = matches.get_one::("profile"); let read = load_manifest(manifest_path, RENDER)?; let compiled = compile(&read, RENDER)?; - let value = match format.as_str() { + let value = match format { "catalog" => render_catalog(&compiled), "evidence-offerings" => render_evidence_offerings(&compiled), "evidence-offering" => { - let offering = option_value(args, "--offering") + let offering = matches + .get_one::("offering") .ok_or_else(|| "evidence-offering render requires --offering ".to_string())?; - render_evidence_offering(&compiled, &offering) + render_evidence_offering(&compiled, offering) .ok_or_else(|| format!("evidence offering not found: {offering}"))? } "policies" => render_policy_collection(&compiled), "policy" => { - let dataset = option_value(args, "--dataset") + let dataset = matches + .get_one::("dataset") .ok_or_else(|| "policy render requires --dataset ".to_string())?; - render_dataset_policy_document(&compiled, &dataset) + render_dataset_policy_document(&compiled, dataset) .ok_or_else(|| format!("dataset not found: {dataset}"))? } "dcat" => { - if let Some(profile) = profile.as_deref() { + if let Some(profile) = profile.map(String::as_str) { ensure_dcat_profile_available(&compiled, profile)?; render_dcat_profile(&compiled, profile) .ok_or_else(|| format!("unsupported DCAT profile: {profile}"))? @@ -406,19 +430,20 @@ fn render_command(args: &[String]) -> Result<(), Failure> { } "shacl" => render_shacl(&compiled), "json-schema" => { - let dataset = option_value(args, "--dataset").ok_or_else(|| { + let dataset = matches.get_one::("dataset").ok_or_else(|| { "json-schema render requires --dataset and --entity ".to_string() })?; - let entity = option_value(args, "--entity").ok_or_else(|| { + let entity = matches.get_one::("entity").ok_or_else(|| { "json-schema render requires --dataset and --entity ".to_string() })?; - render_entity_schema_draft_2020_12(&compiled, &dataset, &entity) + render_entity_schema_draft_2020_12(&compiled, dataset, entity) .ok_or_else(|| format!("entity not found: {dataset}/{entity}"))? } "form-json-schema" => { - let form = option_value(args, "--form") + let form = matches + .get_one::("form") .ok_or_else(|| "form-json-schema render requires --form ".to_string())?; - render_form_schema_draft_2020_12(&compiled, &form) + render_form_schema_draft_2020_12(&compiled, form) .ok_or_else(|| format!("form not found: {form}"))? } "ogc-records" => render_ogc_records_items(&compiled), @@ -427,11 +452,10 @@ fn render_command(args: &[String]) -> Result<(), Failure> { Ok(print_json(&value)?) } -fn publish_command(args: &[String]) -> Result<(), Failure> { - let manifest_path = args.first().ok_or(Failure::Usage( - "publish needs the path of a metadata manifest", - ))?; - let out = option_value(args, "--out").unwrap_or_else(|| "public/metadata".to_string()); +fn publish_command(matches: &ArgMatches) -> Result<(), Failure> { + let manifest_path = match_value(matches, "metadata")?; + let out = match_value(matches, "output")?; + let json = matches.get_one::("format").map(String::as_str) == Some("json"); let read = load_manifest(manifest_path, PUBLISH)?; let compiled = compile(&read, PUBLISH)?; let source_digest = manifest_digest(&read).map_err(|diagnostic| { @@ -445,7 +469,8 @@ fn publish_command(args: &[String]) -> Result<(), Failure> { })?; let out = PathBuf::from(out); let out_root = prepare_publish_root(&out, "metadata.publish.out_not_directory")?; - let site_root = option_value(args, "--site-root") + let site_root = matches + .get_one::("site-root") .map(PathBuf::from) .map(|site_root| { prepare_publish_root(&site_root, "metadata.publish.site_root_not_directory") @@ -641,10 +666,19 @@ fn publish_command(args: &[String]) -> Result<(), Failure> { write_json(&out_root, "index.json", &index)?; let public_root = site_root.as_ref().unwrap_or(&out_root); write_well_known_discovery(public_root, &index)?; - println!( - "published metadata artifacts to {}", - out_root.root.display() - ); + if json { + let mut report = Report::new(Vec::new()); + report.set_files_checked(1); + let mut written = CtlReport::new("publish", 0, &report); + written.source_manifest_digest = Some(&source_digest); + written.output = Some(out_root.root.display().to_string()); + write_report(&written); + } else { + println!( + "published metadata artifacts to {}", + out_root.root.display() + ); + } Ok(()) } @@ -889,9 +923,11 @@ fn write_legacy_registry_manifest_discovery( ) } -fn option_value(args: &[String], name: &str) -> Option { - args.windows(2) - .find_map(|window| (window[0] == name).then(|| window[1].clone())) +fn match_value<'a>(matches: &'a ArgMatches, name: &str) -> Result<&'a str, Failure> { + matches + .get_one::(name) + .map(String::as_str) + .ok_or(Failure::Usage("the command is missing a required argument")) } fn ensure_dcat_profile_available( @@ -1047,5 +1083,9 @@ fn print_json(value: &serde_json::Value) -> Result<(), String> { } fn usage() -> String { - "usage: registry-manifest validate [--format human|json] [--deny-warnings] | validate-profiles [profiles-dir] [--format human|json] [--deny-warnings] | render --format [--profile ] [--dataset --entity ] [--form ] [--offering ] | publish --out [--site-root ]".to_string() + command() + .bin_name("registry-manifest") + .display_name("registry-manifest") + .render_usage() + .to_string() } diff --git a/crates/registry-manifest-cli/tests/cli.rs b/crates/registry-manifest-cli/tests/cli.rs index d7ae227f04..9bf571a1c2 100644 --- a/crates/registry-manifest-cli/tests/cli.rs +++ b/crates/registry-manifest-cli/tests/cli.rs @@ -89,7 +89,7 @@ fn help_flags_exit_zero_and_print_usage_to_stdout() { ); let stdout = String::from_utf8(output.stdout).expect("stdout utf8"); assert!( - stdout.contains("usage:"), + stdout.contains("Usage:"), "{flag} stdout must contain usage text; got: {stdout}" ); assert!( @@ -173,7 +173,204 @@ fn no_command_keeps_the_existing_usage_error() { assert!(output.stdout.is_empty()); assert!(String::from_utf8(output.stderr) .expect("stderr utf8") - .contains("usage: registry-manifest validate")); + .contains("Usage: registry-manifest")); +} + +#[test] +fn singleton_options_unknown_options_and_surplus_arguments_fail_before_effects() { + let root = temp_dir("strict-cli-options"); + let manifest = root.join("metadata.yaml"); + write_minimal_manifest(&manifest, "datasets: []\n"); + + let cases: &[&[&str]] = &[ + &[ + "validate", + manifest.to_str().unwrap(), + "--format", + "human", + "--format", + "human", + ], + &[ + "render", + manifest.to_str().unwrap(), + "--artifact-format", + "catalog", + "--unknown", + ], + &[ + "render", + manifest.to_str().unwrap(), + "--artifact-format", + "catalog", + "surplus", + ], + &[ + "publish", + manifest.to_str().unwrap(), + "--output", + "intended", + "--site-root", + "site", + "--site-root", + "site", + ], + ]; + for arguments in cases { + let output = Command::new(bin()) + .current_dir(&root) + .args(*arguments) + .output() + .expect("run strict CLI case"); + assert_eq!( + output.status.code(), + Some(2), + "{arguments:?} must be a usage refusal; stdout: {}; stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!(!root.join("intended").exists()); + assert!(!root.join("site").exists()); + assert!(!root.join("public").exists()); + } +} + +#[test] +fn publish_output_is_the_only_default_publication_destination() { + let root = temp_dir("publish-output"); + let manifest = root.join("metadata.yaml"); + let intended = root.join("intended"); + write_minimal_manifest(&manifest, "datasets: []\n"); + + let output = Command::new(bin()) + .current_dir(&root) + .args([ + "publish", + manifest.to_str().unwrap(), + "--output", + intended.to_str().unwrap(), + ]) + .output() + .expect("publish into the requested output directory"); + + assert!( + output.status.success(), + "stdout: {}; stderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!(intended.join("index.json").is_file()); + assert!(intended.join(".well-known/api-catalog").is_file()); + assert!( + !root.join("public").exists(), + "publish ignored --output and used its historical default" + ); +} + +#[test] +fn render_artifact_format_preserves_exact_json_stdout_without_a_report_wrapper() { + let root = temp_dir("render-artifact-stdout"); + let manifest = root.join("metadata.yaml"); + write_minimal_manifest(&manifest, "datasets: []\n"); + + let output = Command::new(bin()) + .args([ + "render", + manifest.to_str().unwrap(), + "--artifact-format", + "catalog", + ]) + .output() + .expect("render catalog artifact"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let artifact: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("artifact JSON"); + let expected = format!( + "{}\n", + serde_json::to_string_pretty(&artifact).expect("pretty artifact JSON") + ); + assert_eq!(output.stdout, expected.as_bytes()); + assert_eq!(artifact["schema_version"], "registry-manifest-catalog/v1"); + assert!( + artifact.get("kind").is_none(), + "artifact was wrapped as a report" + ); +} + +#[test] +fn publish_json_reports_separately_from_the_output_artifacts() { + let root = temp_dir("publish-json-report"); + let manifest = root.join("metadata.yaml"); + let intended = root.join("intended"); + write_minimal_manifest(&manifest, "datasets: []\n"); + + let output = Command::new(bin()) + .args([ + "publish", + manifest.to_str().unwrap(), + "--output", + intended.to_str().unwrap(), + "--format", + "json", + ]) + .output() + .expect("publish with JSON report"); + + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("report JSON"); + assert_eq!(report["command"], "publish"); + assert_eq!(report["status"], "complete"); + assert_eq!(report["output"], intended.display().to_string()); + assert!(report["sourceManifestDigest"] + .as_str() + .is_some_and(|digest| digest.starts_with("sha256:"))); + assert!(intended.join("index.json").is_file()); +} + +#[test] +fn retired_manifest_options_name_their_replacements_before_effects() { + let root = temp_dir("retired-cli-options"); + let manifest = root.join("metadata.yaml"); + write_minimal_manifest(&manifest, "datasets: []\n"); + + let retired = [ + ( + vec![ + "publish", + manifest.to_str().unwrap(), + "--out", + "retired-output", + ], + "--output", + ), + ( + vec!["render", manifest.to_str().unwrap(), "--format", "catalog"], + "--artifact-format", + ), + ( + vec!["validate", manifest.to_str().unwrap(), "--json"], + "--format json", + ), + ]; + for (arguments, replacement) in retired { + let output = Command::new(bin()) + .current_dir(&root) + .args(arguments) + .output() + .expect("run retired option case"); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); + assert!( + stderr.contains(replacement), + "retired option must name {replacement}; got {stderr:?}" + ); + assert!(!root.join("retired-output").exists()); + assert!(!root.join("public").exists()); + } } #[test] @@ -184,7 +381,7 @@ fn render_rejects_undeclared_dcat_profile() { .args([ "render", manifest.to_str().unwrap(), - "--format", + "--artifact-format", "dcat", "--profile", "bregdcat-ap", @@ -212,7 +409,12 @@ fn validate_and_render_multi_dataset_distribution_fixture() { ); let render = Command::new(bin()) - .args(["render", manifest.to_str().unwrap(), "--format", "dcat"]) + .args([ + "render", + manifest.to_str().unwrap(), + "--artifact-format", + "dcat", + ]) .output() .expect("render multi-dataset fixture"); assert!( @@ -363,7 +565,7 @@ fn publish_writes_every_indexed_artifact_without_undeclared_profiles() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .output() @@ -421,7 +623,7 @@ fn publish_writes_stable_package_digest_and_artifact_digests() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), "--site-root", site_root.to_str().unwrap(), @@ -503,7 +705,7 @@ fn publish_bundle_readers_tolerate_additive_artifacts() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .output() @@ -558,7 +760,12 @@ fn render_and_publish_cpsv_ap_service_catalogue() { let manifest = manifest_product_root().join("fixtures/cpsv-ap/health-linked-child-support.metadata.yaml"); let output = Command::new(bin()) - .args(["render", manifest.to_str().unwrap(), "--format", "cpsv-ap"]) + .args([ + "render", + manifest.to_str().unwrap(), + "--artifact-format", + "cpsv-ap", + ]) .output() .expect("run cli"); @@ -589,7 +796,7 @@ fn render_and_publish_cpsv_ap_service_catalogue() { .args([ "render", manifest.to_str().unwrap(), - "--format", + "--artifact-format", "form-json-schema", "--form", "child-support-review-form", @@ -610,7 +817,7 @@ fn render_and_publish_cpsv_ap_service_catalogue() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .output() @@ -701,7 +908,7 @@ datasets: .args([ "render", manifest.to_str().unwrap(), - "--format", + "--artifact-format", "evidence-offerings", ]) .output() @@ -719,7 +926,7 @@ datasets: .args([ "render", manifest.to_str().unwrap(), - "--format", + "--artifact-format", "policy", "--dataset", "vital-events", @@ -740,7 +947,7 @@ datasets: .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .output() @@ -978,7 +1185,7 @@ fn publish_default_writes_only_inside_out() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .status() @@ -991,7 +1198,7 @@ fn publish_default_writes_only_inside_out() { .collect::>(); assert!( stray.is_empty(), - "publish wrote files outside --out: {stray:?}" + "publish wrote files outside --output: {stray:?}" ); assert!(out.join(".well-known").join("api-catalog").exists()); @@ -1014,7 +1221,7 @@ fn publish_with_site_root_writes_well_known_under_site_root_only() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), "--site-root", site_root.to_str().unwrap(), @@ -1038,7 +1245,7 @@ fn publish_with_site_root_writes_well_known_under_site_root_only() { .collect::>(); assert!( stray.is_empty(), - "publish wrote files outside --out ∪ --site-root: {stray:?}" + "publish wrote files outside --output ∪ --site-root: {stray:?}" ); } @@ -1060,7 +1267,7 @@ fn publish_rejects_preexisting_symlink_directories_under_out() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .output() @@ -1101,7 +1308,7 @@ datasets: [] .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .output() @@ -1127,14 +1334,14 @@ fn publish_fails_closed_when_out_cannot_be_created() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .output() .expect("run cli"); assert!( !output.status.success(), - "publish must exit non-zero when --out cannot be created" + "publish must exit non-zero when --output cannot be created" ); } @@ -1173,7 +1380,7 @@ datasets: [] .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), ]) .output() @@ -1185,7 +1392,7 @@ datasets: [] ); assert!( !outside.join(format!("{name}-escape.json")).exists(), - "{name} profile id escape wrote outside --out" + "{name} profile id escape wrote outside --output" ); } } @@ -1211,7 +1418,12 @@ fn render_reports_required_flag_errors() { ), ] { let output = Command::new(bin()) - .args(["render", manifest.to_str().unwrap(), "--format", format]) + .args([ + "render", + manifest.to_str().unwrap(), + "--artifact-format", + format, + ]) .output() .expect("run cli"); assert!(!output.status.success(), "format {format} must fail closed"); @@ -1230,13 +1442,19 @@ fn render_reports_lookup_errors_and_unsupported_format() { let cases: &[(&[&str], &str)] = &[ ( - &["render", "--format", "policy", "--dataset", "missing"], + &[ + "render", + "--artifact-format", + "policy", + "--dataset", + "missing", + ], "dataset not found: missing", ), ( &[ "render", - "--format", + "--artifact-format", "json-schema", "--dataset", "missing", @@ -1248,7 +1466,7 @@ fn render_reports_lookup_errors_and_unsupported_format() { ( &[ "render", - "--format", + "--artifact-format", "form-json-schema", "--form", "missing", @@ -1258,7 +1476,7 @@ fn render_reports_lookup_errors_and_unsupported_format() { ( &[ "render", - "--format", + "--artifact-format", "evidence-offering", "--offering", "missing", @@ -1266,8 +1484,8 @@ fn render_reports_lookup_errors_and_unsupported_format() { "evidence offering not found: missing", ), ( - &["render", "--format", "unicorn-schema"], - "unsupported render format: unicorn-schema", + &["render", "--artifact-format", "unicorn-schema"], + "command arguments are invalid", ), ]; @@ -1292,7 +1510,7 @@ fn render_dcat_with_unsupported_profile_fails_closed() { .args([ "render", manifest.to_str().unwrap(), - "--format", + "--artifact-format", "dcat", "--profile", "not-a-real-profile", @@ -1568,7 +1786,7 @@ fn unknown_subcommand_returns_usage() { .expect("run cli"); assert_eq!(output.status.code(), Some(2)); let stderr = String::from_utf8(output.stderr).expect("stderr utf8"); - assert!(stderr.contains("usage:")); + assert!(stderr.contains("Usage:")); } #[test] @@ -1929,7 +2147,7 @@ fn publish_fails_closed_when_site_root_is_a_file() { .args([ "publish", manifest.to_str().unwrap(), - "--out", + "--output", out.to_str().unwrap(), "--site-root", site_root.to_str().unwrap(), @@ -1957,7 +2175,12 @@ fn validate_and_render_multi_vocabulary_field_concepts() { ); let output = Command::new(bin()) - .args(["render", manifest.to_str().unwrap(), "--format", "shacl"]) + .args([ + "render", + manifest.to_str().unwrap(), + "--artifact-format", + "shacl", + ]) .output() .expect("run cli"); assert!( diff --git a/crates/registry-messaging-client-node/README.md b/crates/registry-messaging-client-node/README.md index 9045139349..c2c6739ff2 100644 --- a/crates/registry-messaging-client-node/README.md +++ b/crates/registry-messaging-client-node/README.md @@ -18,7 +18,7 @@ The client never waits on a 429 or retries it. A submission whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same -key up to `maxMutationRetries` times: 0 to 2, default 2, and 0 sends it once. +key up to `maximumMutationRetries` times: 0 to 2, default 2, and 0 sends it once. When the returned `MessagingClientError` still reports `outcomeUnknown`, the submission may have been accepted: recover by sending it again under the same key, because a new key could send the message twice. `outcomeUnknown` is false diff --git a/crates/registry-messaging-client-node/__test__/facade.test-d.ts b/crates/registry-messaging-client-node/__test__/facade.test-d.ts index 0d7f899c40..6d6bc3beec 100644 --- a/crates/registry-messaging-client-node/__test__/facade.test-d.ts +++ b/crates/registry-messaging-client-node/__test__/facade.test-d.ts @@ -11,8 +11,16 @@ import { type TemplatePreviewRequest, } from '../client' -const client = new MessagingClient({ baseUrl: 'https://messaging.example.test/' }) -const once = new MessagingClient({ baseUrl: 'https://messaging.example.test/', maxMutationRetries: 0 }) +const client = new MessagingClient({ + baseUrl: 'https://messaging.example.test/', + maximumResponseBytes: 1500, + trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----', +}) +// @ts-expect-error the public response-bound name is maximumResponseBytes +new MessagingClient({ baseUrl: 'https://messaging.example.test/', maxResponseBytes: 1500 }) +// @ts-expect-error null is not an omission in JavaScript configuration +new MessagingClient({ baseUrl: 'https://messaging.example.test/', attemptTimeoutMilliseconds: null }) +const once = new MessagingClient({ baseUrl: 'https://messaging.example.test/', maximumMutationRetries: 0 }) void once const token: string = 'header.payload.signature' const messageId = '0f8c2a51-6d3e-4b7a-9c10-2e5f7a8b9c0d' diff --git a/crates/registry-messaging-client-node/__test__/facade.test.js b/crates/registry-messaging-client-node/__test__/facade.test.js index 548ee27557..0a0a341cea 100644 --- a/crates/registry-messaging-client-node/__test__/facade.test.js +++ b/crates/registry-messaging-client-node/__test__/facade.test.js @@ -443,14 +443,13 @@ test('a configuration member outside the declared set is a configuration error', const baseUrl = 'https://messaging.example/'; assert.ok(new MessagingClient({ baseUrl, - requestTimeoutMilliseconds: 1500, + attemptTimeoutMilliseconds: 1500, connectTimeoutMilliseconds: 1500, - maxResponseBytes: 1500, + maximumResponseBytes: 1500, userAgent: 'messaging-test', - trustedRootCertificates: null, - maxMutationRetries: 1, + maximumMutationRetries: 1, })); - for (const member of ['maxMutationRetry', 'requestTimeoutMs', 'authorization']) { + for (const member of ['maxMutationRetries', 'maximumMutationRetry', 'requestTimeoutMilliseconds', 'attemptTimeoutMs', 'maxResponseBytes', 'authorization']) { for (const value of [0, null]) { assert.throws(() => new MessagingClient({ baseUrl, [member]: value }), (error) => { assert.ok(error instanceof MessagingClientError, member); @@ -462,17 +461,25 @@ test('a configuration member outside the declared set is a configuration error', } }); -test('every optional configuration member accepts null, as its declaration says', () => { - const { MessagingClient } = require('../client'); +test('null is not an omission for non-nullable JavaScript configuration', () => { + const { MessagingClient, MessagingClientError } = require('../client'); for (const member of [ - 'requestTimeoutMilliseconds', + 'attemptTimeoutMilliseconds', 'connectTimeoutMilliseconds', - 'maxResponseBytes', + 'maximumResponseBytes', 'userAgent', 'trustedRootCertificates', - 'maxMutationRetries', + 'maximumMutationRetries', ]) { - assert.ok(new MessagingClient({ baseUrl: 'https://messaging.example/', [member]: null }), member); + assert.throws( + () => new MessagingClient({ baseUrl: 'https://messaging.example/', [member]: null }), + (error) => { + assert.ok(error instanceof MessagingClientError, member); + assert.equal(error.kind, 'configuration', member); + assert.equal(error.outcomeUnknown, false, member); + return true; + }, + ); } }); @@ -482,14 +489,14 @@ test('a timeout or response bound that is not a whole number in range is a confi // A 32-bit conversion would read -1 as 4294967295, 1.5 as 1, 2 ** 32 as 0, // and 2 ** 32 + 1024 as 1024, so each value is checked whole. const accepted = { - requestTimeoutMilliseconds: [1500, 2 ** 32], + attemptTimeoutMilliseconds: [1500, 2 ** 32], connectTimeoutMilliseconds: [1500, 2 ** 32], - maxResponseBytes: [1500], + maximumResponseBytes: [1500], }; const refused = { - requestTimeoutMilliseconds: [-1, 1.5, '1500'], - connectTimeoutMilliseconds: [-1, 1.5, '1500'], - maxResponseBytes: [-1, 1.5, 2 ** 32, 2 ** 32 + 1024, '1500'], + attemptTimeoutMilliseconds: [true, false, -1, 1.5, '1500'], + connectTimeoutMilliseconds: [true, false, -1, 1.5, '1500'], + maximumResponseBytes: [true, false, -1, 1.5, 2 ** 32, 2 ** 32 + 1024, '1500'], }; for (const [field, values] of Object.entries(accepted)) { for (const value of values) { @@ -512,11 +519,12 @@ test('a configuration value the sanitizer refuses is a configuration error', () const { MessagingClient, MessagingClientError } = require('../client'); const baseUrl = 'https://messaging.example/'; for (const [label, config] of [ - ['an unsafe integer', { baseUrl, requestTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], - ['a non-finite number', { baseUrl, maxResponseBytes: Infinity }], + ['an unsafe integer', { baseUrl, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['a non-finite number', { baseUrl, maximumResponseBytes: Infinity }], + ['a binary trust bundle', { baseUrl, trustedRootCertificates: Buffer.from('not PEM text') }], ['an undefined member', { baseUrl, userAgent: undefined }], ['a function', { baseUrl, userAgent: () => 'messaging-test' }], - ['a date', { baseUrl, requestTimeoutMilliseconds: new Date(0) }], + ['a date', { baseUrl, attemptTimeoutMilliseconds: new Date(0) }], ['a proxy', new Proxy({ baseUrl }, {})], ['no configuration', undefined], ]) { @@ -599,7 +607,7 @@ test('a submission answered 503 is resent once under the same key and succeeds', test('a retry ceiling of zero sends a submission once and reports the outcome unknown', async (context) => { const { baseUrl, requests } = await serve(context, scripted([UNAVAILABLE, RECEIPT])); const { MessagingClient, MessagingClientError } = require('../client'); - const client = new MessagingClient({ baseUrl, maxMutationRetries: 0 }); + const client = new MessagingClient({ baseUrl, maximumMutationRetries: 0 }); await assert.rejects(client.submit('one-call-secret', 'key-1', SUBMISSION), (error) => { assert.ok(error instanceof MessagingClientError); @@ -623,7 +631,7 @@ test('a 4xx refusal of a submission is never resent and reports the outcome know RECEIPT, ])); const { MessagingClient } = require('../client'); - const client = new MessagingClient({ baseUrl, maxMutationRetries: 2 }); + const client = new MessagingClient({ baseUrl, maximumMutationRetries: 2 }); await assert.rejects(client.submit('one-call-secret', 'key-1', SUBMISSION), (error) => { assert.equal(error.kind, 'problem'); @@ -636,13 +644,13 @@ test('a 4xx refusal of a submission is never resent and reports the outcome know test('a retry ceiling outside zero to two is a configuration error', () => { const { MessagingClient, MessagingClientError } = require('../client'); - for (const maxMutationRetries of [0, 1, 2]) { - assert.ok(new MessagingClient({ baseUrl: 'https://messaging.example/', maxMutationRetries })); + for (const maximumMutationRetries of [0, 1, 2]) { + assert.ok(new MessagingClient({ baseUrl: 'https://messaging.example/', maximumMutationRetries })); } - for (const maxMutationRetries of [3, 255, 256, -1, 1.5, '1']) { - assert.throws(() => new MessagingClient({ baseUrl: 'https://messaging.example/', maxMutationRetries }), (error) => { - assert.ok(error instanceof MessagingClientError, `${maxMutationRetries}`); - assert.equal(error.kind, 'configuration', `${maxMutationRetries}`); + for (const maximumMutationRetries of [true, false, 3, 255, 256, -1, 1.5, '1']) { + assert.throws(() => new MessagingClient({ baseUrl: 'https://messaging.example/', maximumMutationRetries }), (error) => { + assert.ok(error instanceof MessagingClientError, `${maximumMutationRetries}`); + assert.equal(error.kind, 'configuration', `${maximumMutationRetries}`); assert.equal(error.outcomeUnknown, false); return true; }); @@ -674,7 +682,7 @@ for (const [code, status, title, detail] of [ test(`receipt observation maps ${code} without retry or exposing caller values`, async (context) => { const { baseUrl, requests } = await serve(context, () => problem(code, status, title, detail)); const { MessagingClient, MessagingClientError } = require('../client'); - const client = new MessagingClient({ baseUrl, maxMutationRetries: 2 }); + const client = new MessagingClient({ baseUrl, maximumMutationRetries: 2 }); await assert.rejects(client.messageReceipt('token-canary', 'key-canary', SUBMISSION), (error) => { assert.ok(error instanceof MessagingClientError); assert.equal(error.kind, 'problem'); diff --git a/crates/registry-messaging-client-node/client.d.ts b/crates/registry-messaging-client-node/client.d.ts index de9aff0994..4d31e4d550 100644 --- a/crates/registry-messaging-client-node/client.d.ts +++ b/crates/registry-messaging-client-node/client.d.ts @@ -9,18 +9,19 @@ export type JsonValue = JsonScalar | ReadonlyArray | { readonly [key: export interface MessagingClientConfig { baseUrl: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + /** Additional trust roots for the deployment, as PEM text. */ + trustedRootCertificates?: string /** * How many times a submission whose outcome is unknown is resent, * identically and under the same idempotency key, before its error is * returned: a whole number from 0 to 2, default 2; 0 sends it once. Any * other value is a `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export type Channel = 'email' | 'sms' diff --git a/crates/registry-messaging-client-node/src/lib.rs b/crates/registry-messaging-client-node/src/lib.rs index f857711bad..235ab19d3b 100644 --- a/crates/registry-messaging-client-node/src/lib.rs +++ b/crates/registry-messaging-client-node/src/lib.rs @@ -24,14 +24,14 @@ const MAXIMUM_JAVASCRIPT_SAFE_INTEGER: i64 = 9_007_199_254_740_991; #[serde(rename_all = "camelCase", deny_unknown_fields)] struct MessagingClientConfig { base_url: String, - request_timeout_milliseconds: Option, + attempt_timeout_milliseconds: Option, connect_timeout_milliseconds: Option, - max_response_bytes: Option, + maximum_response_bytes: Option, user_agent: Option, trusted_root_certificates: Option, /// How many times a submission whose outcome is unknown is resent under /// the same idempotency key: 0 to 2, default 2, and 0 disables the resend. - max_mutation_retries: Option, + maximum_mutation_retries: Option, } #[napi(object)] @@ -50,6 +50,7 @@ pub struct MessagingClient { impl MessagingClient { #[napi(constructor)] pub fn new(config: Value) -> Result { + reject_null_configuration_members(&config)?; let config: MessagingClientConfig = serde_json::from_value(config).map_err(|_| { binding_error("configuration", "Messaging client configuration is invalid") })?; @@ -57,14 +58,14 @@ impl MessagingClient { binding_error("configuration", "Messaging client configuration is invalid") })?; let mut core = CoreConfig::new(base_url); - if let Some(value) = config.request_timeout_milliseconds { - core = core.with_request_timeout(Duration::from_millis(whole_number(value)?)); + if let Some(value) = config.attempt_timeout_milliseconds { + core = core.with_attempt_timeout(Duration::from_millis(whole_number(value)?)); } if let Some(value) = config.connect_timeout_milliseconds { core = core.with_connect_timeout(Duration::from_millis(whole_number(value)?)); } - if let Some(value) = config.max_response_bytes { - core = core.with_max_response_bytes(whole_number(value)?); + if let Some(value) = config.maximum_response_bytes { + core = core.with_maximum_response_bytes(whole_number(value)?); } if let Some(value) = config.user_agent { core = core.with_user_agent(value); @@ -72,8 +73,8 @@ impl MessagingClient { if let Some(value) = config.trusted_root_certificates { core = core.with_trusted_root_certificates(value.into_bytes()); } - if let Some(value) = config.max_mutation_retries { - core = core.with_max_mutation_retries(mutation_retries(value)?); + if let Some(value) = config.maximum_mutation_retries { + core = core.with_maximum_mutation_retries(mutation_retries(value)?); } CoreClient::new(core) .map(|inner| Self { inner }) @@ -289,6 +290,21 @@ fn binding_error(kind: &'static str, message: &'static str) -> NapiError { ) } +/// JavaScript `null` is a value, not omission. Optional configuration is +/// omitted by leaving the member absent. +fn reject_null_configuration_members(config: &Value) -> Result<()> { + if config + .as_object() + .is_some_and(|members| members.values().any(Value::is_null)) + { + return Err(binding_error( + "configuration", + "Messaging client configuration is invalid", + )); + } + Ok(()) +} + /// A millisecond or byte count as a JavaScript number. Only a whole number in /// the safe integer range reaches the client, which applies its own bounds; a /// 32-bit conversion would wrap a negative, fractional, or larger number. diff --git a/crates/registry-messaging-client-py/README.md b/crates/registry-messaging-client-py/README.md index ddb09da406..94b738e3fa 100644 --- a/crates/registry-messaging-client-py/README.md +++ b/crates/registry-messaging-client-py/README.md @@ -50,7 +50,7 @@ on a 429 or retries it. A submission whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same -key up to `max_mutation_retries` times: 0 to 2, default 2, and 0 sends it +key up to `maximum_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. When the returned `MessagingClientError` still reports `outcome_unknown`, the submission may have been accepted: recover by sending it again under the same key, because a new key could send the message twice. diff --git a/crates/registry-messaging-client-py/python/registry_messaging_client/__init__.pyi b/crates/registry-messaging-client-py/python/registry_messaging_client/__init__.pyi index 7841840bab..2dad5ab307 100644 --- a/crates/registry-messaging-client-py/python/registry_messaging_client/__init__.pyi +++ b/crates/registry-messaging-client-py/python/registry_messaging_client/__init__.pyi @@ -190,7 +190,7 @@ class MessagingClientError(Exception): outcome_unknown: bool class MessagingClient: - def __init__(self, base_url: str, request_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, max_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, max_mutation_retries: int | None = None) -> None: ... + def __init__(self, base_url: str, attempt_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, maximum_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, maximum_mutation_retries: int | None = None) -> None: ... def health(self) -> Complete[None]: ... def ready(self) -> Complete[None]: ... def submit(self, token: str, idempotency_key: str, request: SubmitMessageRequest) -> Complete[MessageReceipt]: ... diff --git a/crates/registry-messaging-client-py/src/lib.rs b/crates/registry-messaging-client-py/src/lib.rs index 80d43435dd..f2039e86fe 100644 --- a/crates/registry-messaging-client-py/src/lib.rs +++ b/crates/registry-messaging-client-py/src/lib.rs @@ -13,7 +13,7 @@ use messaging_client_sdk::{ use pyo3::{ exceptions::{PyException, PyRuntimeError}, prelude::*, - types::{PyBool, PyDict}, + types::{PyBool, PyDict, PyFloat, PyInt}, }; use serde::{de::DeserializeOwned, Serialize}; use url::Url; @@ -204,18 +204,39 @@ fn mutation_retries(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { /// or `float` reaches the client, which refuses zero; a `bool` is not a /// duration. fn duration(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { - let invalid = || { - binding_error( + if value.is_exact_instance_of::() { + let seconds = value.extract::().map_err(|_| { + binding_error( + py, + "configuration", + "Messaging client configuration is invalid", + ) + })?; + Duration::try_from_secs_f64(seconds).map_err(|_| { + binding_error( + py, + "configuration", + "client timeouts must be finite non-negative seconds", + ) + }) + } else if value.is_exact_instance_of::() { + value + .extract::() + .map(Duration::from_secs) + .map_err(|_| { + binding_error( + py, + "configuration", + "Messaging client configuration is invalid", + ) + }) + } else { + Err(binding_error( py, "configuration", - "client timeouts must be finite non-negative seconds", - ) - }; - if value.is_instance_of::() { - return Err(invalid()); + "Messaging client configuration is invalid", + )) } - let seconds = value.extract::().map_err(|_| invalid())?; - Duration::try_from_secs_f64(seconds).map_err(|_| invalid()) } /// The response body bound as a Python integer. Only an `int` in the `u64` @@ -229,7 +250,7 @@ fn response_bytes(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { "Messaging client configuration is invalid", ) }; - if value.is_instance_of::() { + if !value.is_exact_instance_of::() { return Err(invalid()); } value.extract::().map_err(|_| invalid()) @@ -270,17 +291,17 @@ struct MessagingClient { #[pymethods] impl MessagingClient { #[new] - #[pyo3(signature = (base_url, request_timeout_seconds=None, connect_timeout_seconds=None, max_response_bytes=None, user_agent=None, trusted_root_certificates=None, max_mutation_retries=None))] + #[pyo3(signature = (base_url, attempt_timeout_seconds=None, connect_timeout_seconds=None, maximum_response_bytes=None, user_agent=None, trusted_root_certificates=None, maximum_mutation_retries=None))] #[allow(clippy::too_many_arguments)] fn new( py: Python<'_>, base_url: &str, - request_timeout_seconds: Option<&Bound<'_, PyAny>>, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, connect_timeout_seconds: Option<&Bound<'_, PyAny>>, - max_response_bytes: Option<&Bound<'_, PyAny>>, + maximum_response_bytes: Option<&Bound<'_, PyAny>>, user_agent: Option, trusted_root_certificates: Option>, - max_mutation_retries: Option<&Bound<'_, PyAny>>, + maximum_mutation_retries: Option<&Bound<'_, PyAny>>, ) -> PyResult { let base_url = Url::parse(base_url).map_err(|_| { binding_error( @@ -290,14 +311,14 @@ impl MessagingClient { ) })?; let mut config = MessagingClientConfig::new(base_url); - if let Some(value) = request_timeout_seconds { - config = config.with_request_timeout(duration(py, value)?); + if let Some(value) = attempt_timeout_seconds { + config = config.with_attempt_timeout(duration(py, value)?); } if let Some(value) = connect_timeout_seconds { config = config.with_connect_timeout(duration(py, value)?); } - if let Some(value) = max_response_bytes { - config = config.with_max_response_bytes(response_bytes(py, value)?); + if let Some(value) = maximum_response_bytes { + config = config.with_maximum_response_bytes(response_bytes(py, value)?); } if let Some(value) = user_agent { config = config.with_user_agent(value); @@ -305,8 +326,8 @@ impl MessagingClient { if let Some(value) = trusted_root_certificates { config = config.with_trusted_root_certificates(value); } - if let Some(value) = max_mutation_retries { - config = config.with_max_mutation_retries(mutation_retries(py, value)?); + if let Some(value) = maximum_mutation_retries { + config = config.with_maximum_mutation_retries(mutation_retries(py, value)?); } let inner = py .detach(|| RustClient::new(config)) diff --git a/crates/registry-messaging-client-py/tests/python/test_construction.py b/crates/registry-messaging-client-py/tests/python/test_construction.py index d49d9867cb..e626e26f4c 100644 --- a/crates/registry-messaging-client-py/tests/python/test_construction.py +++ b/crates/registry-messaging-client-py/tests/python/test_construction.py @@ -30,36 +30,40 @@ def test_invalid_configuration_has_a_stable_kind(self) -> None: def test_negative_timeout_is_a_configuration_error(self) -> None: with self.assertRaises(MessagingClientError) as raised: - MessagingClient("https://messaging.example.invalid/", request_timeout_seconds=-1.0) + MessagingClient("https://messaging.example.invalid/", attempt_timeout_seconds=-1.0) self.assertEqual(raised.exception.kind, "configuration") def test_timeouts_and_response_bound_accept_numbers_in_range(self) -> None: for keyword, value in ( - ("request_timeout_seconds", 2), - ("request_timeout_seconds", 1.5), + ("attempt_timeout_seconds", 2), + ("attempt_timeout_seconds", 1.5), + ("attempt_timeout_seconds", 2**64 - 1), ("connect_timeout_seconds", 2), ("connect_timeout_seconds", 1.5), - ("max_response_bytes", 1500), + ("connect_timeout_seconds", 2**64 - 1), + ("maximum_response_bytes", 1500), ): with self.subTest(keyword=keyword, value=value): MessagingClient("https://messaging.example.invalid/", **{keyword: value}) def test_bool_or_out_of_range_bound_is_a_configuration_error(self) -> None: for keyword, value in ( - ("request_timeout_seconds", True), - ("request_timeout_seconds", False), - ("request_timeout_seconds", -1), - ("request_timeout_seconds", float("nan")), - ("request_timeout_seconds", float("inf")), - ("request_timeout_seconds", "2"), + ("attempt_timeout_seconds", True), + ("attempt_timeout_seconds", False), + ("attempt_timeout_seconds", -1), + ("attempt_timeout_seconds", 2**64), + ("attempt_timeout_seconds", float("nan")), + ("attempt_timeout_seconds", float("inf")), + ("attempt_timeout_seconds", "2"), ("connect_timeout_seconds", True), ("connect_timeout_seconds", -1), - ("max_response_bytes", True), - ("max_response_bytes", False), - ("max_response_bytes", -1), - ("max_response_bytes", 2**64), - ("max_response_bytes", 1.5), - ("max_response_bytes", "1500"), + ("connect_timeout_seconds", 2**64), + ("maximum_response_bytes", True), + ("maximum_response_bytes", False), + ("maximum_response_bytes", -1), + ("maximum_response_bytes", 2**64), + ("maximum_response_bytes", 1.5), + ("maximum_response_bytes", "1500"), ): with self.subTest(keyword=keyword, value=value): with self.assertRaises(MessagingClientError) as raised: @@ -67,6 +71,18 @@ def test_bool_or_out_of_range_bound_is_a_configuration_error(self) -> None: self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) + def test_none_means_omission_for_optional_configuration(self) -> None: + client = MessagingClient( + "https://messaging.example.invalid/", + attempt_timeout_seconds=None, + connect_timeout_seconds=None, + maximum_response_bytes=None, + user_agent=None, + trusted_root_certificates=None, + maximum_mutation_retries=None, + ) + self.assertIsInstance(client, MessagingClient) + def test_token_error_does_not_repeat_credential(self) -> None: malformed = "bad token with spaces canary" client = MessagingClient("https://messaging.example.invalid/") diff --git a/crates/registry-messaging-client-py/tests/python/test_mutation_retry.py b/crates/registry-messaging-client-py/tests/python/test_mutation_retry.py index 4a2a80d093..7f2c940575 100644 --- a/crates/registry-messaging-client-py/tests/python/test_mutation_retry.py +++ b/crates/registry-messaging-client-py/tests/python/test_mutation_retry.py @@ -130,7 +130,7 @@ def test_a_503_is_resent_once_under_the_same_key_and_succeeds(self) -> None: def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> None: self.script(UNAVAILABLE, RECEIPT) - client = MessagingClient(self.base_url, max_mutation_retries=0) + client = MessagingClient(self.base_url, maximum_mutation_retries=0) with self.assertRaises(MessagingClientError) as raised: client.submit("one-call-token", KEY, SUBMISSION) @@ -143,7 +143,7 @@ def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> N def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> None: self.script(KEY_REUSED, RECEIPT) - client = MessagingClient(self.base_url, max_mutation_retries=2) + client = MessagingClient(self.base_url, maximum_mutation_retries=2) with self.assertRaises(MessagingClientError) as raised: client.submit("one-call-token", KEY, SUBMISSION) @@ -156,11 +156,11 @@ def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> No def test_a_ceiling_outside_zero_to_two_is_a_configuration_error(self) -> None: for retries in (0, 1, 2): with self.subTest(retries=retries): - MessagingClient(self.base_url, max_mutation_retries=retries) + MessagingClient(self.base_url, maximum_mutation_retries=retries) for retries in (3, 255, 256, -1, 1.5, "1", True): with self.subTest(retries=retries): with self.assertRaises(MessagingClientError) as raised: - MessagingClient(self.base_url, max_mutation_retries=retries) + MessagingClient(self.base_url, maximum_mutation_retries=retries) self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) diff --git a/crates/registry-messaging-client/src/client.rs b/crates/registry-messaging-client/src/client.rs index a6b2b9625e..3bf08b7110 100644 --- a/crates/registry-messaging-client/src/client.rs +++ b/crates/registry-messaging-client/src/client.rs @@ -46,8 +46,8 @@ pub struct MessagingComplete { pub struct MessagingClient { http: reqwest::Client, base_url: ServiceBaseUrl, - max_response_bytes: u64, - max_mutation_retries: u8, + maximum_response_bytes: u64, + maximum_mutation_retries: u8, } impl fmt::Debug for MessagingClient { @@ -55,8 +55,8 @@ impl fmt::Debug for MessagingClient { formatter .debug_struct("MessagingClient") .field("base_url", &"") - .field("max_response_bytes", &self.max_response_bytes) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_response_bytes", &self.maximum_response_bytes) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -65,7 +65,7 @@ impl MessagingClient { pub fn new(config: MessagingClientConfig) -> Result { let base_url = config.validate()?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config.trusted_root_certificates.as_deref(), @@ -74,8 +74,8 @@ impl MessagingClient { Ok(Self { http, base_url, - max_response_bytes: config.max_response_bytes, - max_mutation_retries: config.max_mutation_retries, + maximum_response_bytes: config.maximum_response_bytes, + maximum_mutation_retries: config.maximum_mutation_retries, }) } @@ -102,7 +102,7 @@ impl MessagingClient { /// The caller chooses the key; the client never invents one. When the /// outcome is unknown, after a timeout or broken exchange or on a 5xx /// answer, the client resends the identical request under the same key, - /// at most `MessagingClientConfig::with_max_mutation_retries` times (2 by + /// at most `MessagingClientConfig::with_maximum_mutation_retries` times (2 by /// default; 0 disables it). An error for which /// `MessagingClientError::is_outcome_unknown` is true may still have /// accepted the message: recover with the same key and request, never a @@ -310,7 +310,7 @@ impl MessagingClient { expected: StatusCode, ) -> Result, MessagingClientError> { let request = &request; - retry_keyed_mutation(self.max_mutation_retries, || async move { + retry_keyed_mutation(self.maximum_mutation_retries, || async move { let Some(attempt) = request.try_clone() else { return KeyedMutationAttempt::Settled(Err(MessagingClientError::invalid_request( "the request could not be prepared for sending", @@ -348,7 +348,7 @@ impl MessagingClient { Some(trace_id), )); } - let body = read_bounded(response, self.max_response_bytes) + let body = read_bounded(response, self.maximum_response_bytes) .await .map_err(|error| MessagingClientError::Transport { kind: read_failure_kind(&error), diff --git a/crates/registry-messaging-client/src/config.rs b/crates/registry-messaging-client/src/config.rs index ab9f400730..cc1dc3a453 100644 --- a/crates/registry-messaging-client/src/config.rs +++ b/crates/registry-messaging-client/src/config.rs @@ -19,12 +19,12 @@ const MAXIMUM_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; #[derive(Clone)] pub struct MessagingClientConfig { pub(crate) base_url: Url, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>, - pub(crate) max_mutation_retries: u8, + pub(crate) maximum_mutation_retries: u8, } impl MessagingClientConfig { @@ -32,18 +32,18 @@ impl MessagingClientConfig { pub fn new(base_url: Url) -> Self { Self { base_url, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_REQUEST_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, - max_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, user_agent: None, trusted_root_certificates: None, - max_mutation_retries: DEFAULT_MUTATION_RETRIES, + maximum_mutation_retries: DEFAULT_MUTATION_RETRIES, } } #[must_use] - pub fn with_request_timeout(mut self, value: Duration) -> Self { - self.request_timeout = value; + pub fn with_attempt_timeout(mut self, value: Duration) -> Self { + self.attempt_timeout = value; self } @@ -54,8 +54,8 @@ impl MessagingClientConfig { } #[must_use] - pub fn with_max_response_bytes(mut self, value: u64) -> Self { - self.max_response_bytes = value; + pub fn with_maximum_response_bytes(mut self, value: u64) -> Self { + self.maximum_response_bytes = value; self } @@ -76,8 +76,8 @@ impl MessagingClientConfig { /// returned. Defaults to 2, accepts at most 2, and 0 disables the /// resend. #[must_use] - pub fn with_max_mutation_retries(mut self, value: u8) -> Self { - self.max_mutation_retries = value; + pub fn with_maximum_mutation_retries(mut self, value: u8) -> Self { + self.maximum_mutation_retries = value; self } @@ -85,12 +85,13 @@ impl MessagingClientConfig { let base_url = ServiceBaseUrl::new(self.base_url.clone()).map_err(|_| { MessagingClientError::configuration("the service base URL is not usable") })?; - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(MessagingClientError::configuration( "client timeouts must be greater than zero", )); } - if self.max_response_bytes == 0 || self.max_response_bytes > MAXIMUM_RESPONSE_BYTES { + if self.maximum_response_bytes == 0 || self.maximum_response_bytes > MAXIMUM_RESPONSE_BYTES + { return Err(MessagingClientError::configuration( "the response body bound is outside the accepted range", )); @@ -104,7 +105,7 @@ impl MessagingClientConfig { "the trusted root certificate bundle exceeds the accepted bound", )); } - if self.max_mutation_retries > MAXIMUM_MUTATION_RETRIES { + if self.maximum_mutation_retries > MAXIMUM_MUTATION_RETRIES { return Err(MessagingClientError::configuration( "the mutation retry count exceeds the accepted bound", )); @@ -118,15 +119,15 @@ impl fmt::Debug for MessagingClientConfig { formatter .debug_struct("MessagingClientConfig") .field("base_url", &"") - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("user_agent_present", &self.user_agent.is_some()) .field( "trusted_root_certificates_present", &self.trusted_root_certificates.is_some(), ) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -152,12 +153,16 @@ mod tests { #[test] fn configuration_bounds_are_enforced_before_any_client_exists() { let url = Url::parse("https://messaging.example.invalid/").expect("fixture URL"); + assert_eq!( + MessagingClientConfig::new(url.clone()).maximum_response_bytes, + DEFAULT_MAXIMUM_RESPONSE_BYTES + ); let refused = [ - MessagingClientConfig::new(url.clone()).with_request_timeout(Duration::ZERO), + MessagingClientConfig::new(url.clone()).with_attempt_timeout(Duration::ZERO), MessagingClientConfig::new(url.clone()).with_connect_timeout(Duration::ZERO), - MessagingClientConfig::new(url.clone()).with_max_response_bytes(0), + MessagingClientConfig::new(url.clone()).with_maximum_response_bytes(0), MessagingClientConfig::new(url.clone()) - .with_max_response_bytes(MAXIMUM_RESPONSE_BYTES + 1), + .with_maximum_response_bytes(MAXIMUM_RESPONSE_BYTES + 1), MessagingClientConfig::new(url.clone()).with_trusted_root_certificates(vec![ 0; MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES @@ -167,7 +172,7 @@ mod tests { Url::parse("https://user:pass@messaging.example.invalid/").expect("fixture URL"), ), MessagingClientConfig::new(url.clone()) - .with_max_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1), + .with_maximum_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1), ]; for config in refused { assert!( @@ -180,10 +185,14 @@ mod tests { } for retries in 0..=MAXIMUM_MUTATION_RETRIES { assert!(MessagingClientConfig::new(url.clone()) - .with_max_mutation_retries(retries) + .with_maximum_mutation_retries(retries) .validate() .is_ok()); } + assert!(MessagingClientConfig::new(url.clone()) + .with_maximum_response_bytes(MAXIMUM_RESPONSE_BYTES) + .validate() + .is_ok()); assert!(MessagingClientConfig::new(url).validate().is_ok()); } } diff --git a/crates/registry-messaging-client/src/lib.rs b/crates/registry-messaging-client/src/lib.rs index 7d3fc80768..168171b963 100644 --- a/crates/registry-messaging-client/src/lib.rs +++ b/crates/registry-messaging-client/src/lib.rs @@ -28,7 +28,7 @@ //! The client never generates an idempotency key. A submission whose outcome //! is unknown (a timeout or broken exchange after the request was sent, or a //! 5xx answer) is resent, byte for byte and under the same key, at most -//! `MessagingClientConfig::with_max_mutation_retries` times (2 by default, 0 +//! `MessagingClientConfig::with_maximum_mutation_retries` times (2 by default, 0 //! disables it). Reads, the preview, and the unkeyed cancellation are never //! resent. A 5xx answer may follow an acceptance: when //! `MessagingClientError::is_outcome_unknown` is still true, the safe diff --git a/crates/registry-messaging-client/tests/mutation_retry.rs b/crates/registry-messaging-client/tests/mutation_retry.rs index 04673939b4..e249eab170 100644 --- a/crates/registry-messaging-client/tests/mutation_retry.rs +++ b/crates/registry-messaging-client/tests/mutation_retry.rs @@ -185,7 +185,7 @@ fn assert_identical_resends(observations: &[Captured]) { async fn a_timed_out_submission_is_resent_identically_under_the_same_key() { let (address, script, server) = serve(&[Answer::Stall, Answer::Json(StatusCode::ACCEPTED, RECEIPT)]).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let accepted = client .submit(&token(), IDEMPOTENCY_KEY, &submission()) @@ -322,7 +322,7 @@ async fn the_retry_count_is_honored() { .await; let mut config = config(&address); if let Some(retries) = retries { - config = config.with_max_mutation_retries(retries); + config = config.with_maximum_mutation_retries(retries); } let error = client(config) .submit(&token(), IDEMPOTENCY_KEY, &submission()) @@ -350,8 +350,8 @@ async fn zero_retries_disables_the_resend() { serve(&[Answer::Stall, Answer::Json(StatusCode::ACCEPTED, RECEIPT)]).await; let error = client( config(&address) - .with_request_timeout(Duration::from_millis(500)) - .with_max_mutation_retries(0), + .with_attempt_timeout(Duration::from_millis(500)) + .with_maximum_mutation_retries(0), ) .submit(&token(), IDEMPOTENCY_KEY, &submission()) .await @@ -496,7 +496,7 @@ async fn an_unreadable_4xx_answer_is_unknown_but_never_resent() { (Unfinished::Close, TransportKind::Exchange), ] { let (address, requests, server) = serve_unfinished_conflict(ending).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let error = client .submit(&token(), IDEMPOTENCY_KEY, &submission()) .await diff --git a/crates/registry-messaging/src/http.rs b/crates/registry-messaging/src/http.rs index f33bed2d8c..30a4543d6b 100644 --- a/crates/registry-messaging/src/http.rs +++ b/crates/registry-messaging/src/http.rs @@ -51,6 +51,7 @@ use registry_messaging_core::{ PROVIDER_CALLBACK_TOKEN_PATH, READY_PATH, TEMPLATE_PREVIEW_PATH, }; use registry_platform_authcommon::parse_bearer_token; +use registry_platform_canonical_json::parse_json_strict; use registry_platform_httpsec::{ request_body_limit_default, security_headers, CspBuilder, ProblemBody, TraceContext, }; @@ -882,8 +883,7 @@ fn preview_request( if !is_json(headers) { return Err(ProblemCode::RequestUnsupportedMediaType); } - let value: serde_json::Value = - serde_json::from_slice(body).map_err(|_| ProblemCode::RequestInvalid)?; + let value = parse_json_strict(body).map_err(|_| ProblemCode::RequestInvalid)?; serde_json::from_value(value).map_err(|_| ProblemCode::RequestUnprocessable) } @@ -1595,6 +1595,18 @@ mod tests { b"{".to_vec(), ProblemCode::RequestInvalid, ), + ( + PREVIEW, + Some("application/json"), + br#"{"locale": "fr", "locale": "fr", "data": {"name": "Ada Lovelace", "day": "2026-10-01", "office": "Central Registry Office"}}"#.to_vec(), + ProblemCode::RequestInvalid, + ), + ( + PREVIEW, + Some("application/json"), + br#"{"locale": "fr", "data": {"name": "Ada Lovelace", "name": "Ada Lovelace", "day": "2026-10-01", "office": "Central Registry Office"}}"#.to_vec(), + ProblemCode::RequestInvalid, + ), ( PREVIEW, Some("application/json"), @@ -1645,14 +1657,14 @@ mod tests { .await; } let records = journal(&sink); - assert_eq!(records.len(), 9); + assert_eq!(records.len(), 11); // The undeclared locale is not written; the declared template is. assert_eq!( - records[7]["template"], + records[9]["template"], json!({"id": "appointment-reminder", "version": "1"}) ); - assert_eq!(records[7]["problem"], "template.locale-unavailable"); - assert!(records[6].get("template").is_none()); + assert_eq!(records[9]["problem"], "template.locale-unavailable"); + assert!(records[8].get("template").is_none()); for record in &records { assert_eq!(record["outcome"], "refused"); } diff --git a/crates/registry-messagingctl/src/lib.rs b/crates/registry-messagingctl/src/lib.rs index fac8845084..82f4f625bf 100644 --- a/crates/registry-messagingctl/src/lib.rs +++ b/crates/registry-messagingctl/src/lib.rs @@ -45,7 +45,7 @@ use std::ffi::{OsStr, OsString}; use std::io::{self, Read as _}; use std::path::{Path, PathBuf}; -use std::process::ExitCode; +use std::process::{Command as ProcessCommand, ExitCode}; use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum}; use registry_messaging::activation::{ActivationError, ActivationRefusal, ApplyRequest}; @@ -78,6 +78,7 @@ use uuid::Uuid; mod cli_contract_tests; mod dev; mod report; +mod selected_binary; mod starter; use report::{DOMAIN_REFUSAL_EXIT, OPERATIONAL_FAILURE_EXIT, USAGE_EXIT}; @@ -99,6 +100,8 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { + /// Run the production Messaging service with the selected runtime. + Serve(ServeArgs), /// Write a starter authoring project and runtime example. Init(InitArgs), /// Build an immutable runtime package from an editable project. @@ -132,6 +135,17 @@ enum Command { Dev(dev::DevArgs), } +#[derive(Debug, Args)] +struct ServeArgs { + /// Runtime configuration file passed unchanged to the Messaging runtime. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Messaging runtime executable for an unmanaged installation. + #[arg(long, hide = true, env = "MESSAGING_BIN", value_name = "BINARY")] + messaging_bin: Option, +} + #[derive(Debug, Subcommand)] enum RetentionCommand { /// Report what had expired by --before, and erase it with --apply. @@ -630,6 +644,9 @@ where T: Into, { let args = args.into_iter().map(Into::into).collect::>(); + let format_requested = args + .iter() + .any(|arg| arg == OsStr::new("--format") || arg.to_string_lossy().starts_with("--format=")); let machine_mode = args .windows(2) .any(|pair| pair[0] == OsStr::new("--format") && pair[1] == OsStr::new("json")) @@ -661,6 +678,7 @@ where }; let command = command_path(&cli.command); let outcome = match cli.command { + Command::Serve(args) => return serve(args, cli.format, format_requested, stdout, stderr), Command::Dev(args) => return dev::run(args, cli.format, stdout, stderr), Command::Init(args) => init(&args.directory), Command::Package(args) => package(&args), @@ -678,6 +696,7 @@ where /// The subcommand path a parsed invocation selected, as its report names it. fn command_path(command: &Command) -> &'static str { match command { + Command::Serve(_) => "serve", Command::Init(_) => "init", Command::Package(_) => "package", Command::Check(_) => "check", @@ -695,6 +714,85 @@ fn command_path(command: &Command) -> &'static str { } } +fn serve( + args: ServeArgs, + format: OutputFormat, + format_requested: bool, + stdout: &mut dyn io::Write, + stderr: &mut dyn io::Write, +) -> ExitCode { + if format_requested { + let message = + "--format does not apply to serve because service output keeps the Messaging runtime stream" + .to_owned(); + if format == OutputFormat::Json { + let outcome = Outcome::refused(USAGE_EXIT, "usage.invalid", "format", message); + return finish(&outcome, "usage", OutputFormat::Json, stdout, stderr); + } + let _ = writeln!( + stderr, + "error: {message}\n next: Remove --format and retry." + ); + return ExitCode::from(USAGE_EXIT); + } + + let binary = match selected_binary::resolve_managed( + selected_binary::MESSAGING, + args.messaging_bin.as_deref(), + ) { + Ok(Some(binary)) => binary, + Ok(None) => args + .messaging_bin + .unwrap_or_else(|| PathBuf::from("messaging")), + Err(error) => { + let _ = writeln!( + stderr, + "messagingctl serve could not select messaging: {error}" + ); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + command + .arg("--runtime-config") + .arg(args.runtime_config) + .arg("serve"); + replace_with_service(command, "messaging", stderr) +} + +#[cfg(unix)] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn io::Write, +) -> ExitCode { + use std::os::unix::process::CommandExt as _; + + let error = command.exec(); + let _ = writeln!(stderr, "messagingctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) +} + +#[cfg(not(unix))] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn io::Write, +) -> ExitCode { + match command.status() { + Ok(status) => ExitCode::from( + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(OPERATIONAL_FAILURE_EXIT), + ), + Err(error) => { + let _ = writeln!(stderr, "messagingctl serve could not start {name}: {error}"); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + fn package(args: &PackageArgs) -> Outcome { let inputs = match package_inputs(&args.project) { Ok(inputs) => inputs, diff --git a/crates/registry-messagingctl/src/selected_binary.rs b/crates/registry-messagingctl/src/selected_binary.rs new file mode 100644 index 0000000000..2806e8ae08 --- /dev/null +++ b/crates/registry-messagingctl/src/selected_binary.rs @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::fs; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +type Result = std::result::Result; + +#[derive(Clone, Copy)] +pub(crate) struct HelperSpec { + pub(crate) binary: &'static str, + pub(crate) component: &'static str, + pub(crate) explicit_source: &'static str, + pub(crate) managed_env: &'static str, +} + +pub(crate) const MESSAGING: HelperSpec = HelperSpec { + binary: "messaging", + component: "messaging", + explicit_source: "--messaging-bin or MESSAGING_BIN", + managed_env: "RSTACK_MESSAGING_BIN", +}; + +pub(crate) fn resolve_managed( + spec: HelperSpec, + explicit: Option<&Path>, +) -> Result> { + resolve_managed_with_environment( + spec, + explicit, + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(spec.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + ) +} + +fn resolve_managed_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result> { + let Some(generation_dir) = generation_dir else { + return Ok(None); + }; + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(Path::new(generation_dir)).map_err(|_| { + format!( + "rstack {release} selected generation {} cannot be resolved", + Path::new(generation_dir).display() + ) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let Some(managed_path) = managed_path else { + return missing_component(spec, &release); + }; + let selected = + canonical_helper(Path::new(managed_path)).or_else(|_| missing_component(spec, &release))?; + let layout = canonical_helper(&layout_path).or_else(|_| missing_component(spec, &release))?; + if selected != layout { + return Err(format!( + "rstack {release} provided {} outside the selected generation helper path at {}; run `rstack install {}` to repair the selected generation", + spec.managed_env, + layout_path.display(), + spec.component + )); + } + if let Some(override_path) = explicit { + let override_identity = canonical_helper(override_path).map_err(|_| { + format!( + "cannot resolve {} from {}", + spec.binary, spec.explicit_source + ) + })?; + if override_identity != selected { + return Err(format!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected generation identity", + spec.explicit_source, + override_path.display(), + spec.binary, + selected.display() + )); + } + } + Ok(Some(selected)) +} + +fn canonical_helper(path: &Path) -> Result { + if !path.is_file() { + return Err(format!( + "helper is not an installed file at {}", + path.display() + )); + } + fs::canonicalize(path).map_err(|error| error.to_string()) +} + +fn missing_component(spec: HelperSpec, release: &str) -> Result { + Err(format!( + "rstack {release} selected generation has no usable {} helper; run `rstack install {}` and retry, messagingctl will not install components automatically", + spec.binary, + spec.component + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(root: &Path) -> (PathBuf, PathBuf) { + let generation = root.join("generation"); + let selected = generation.join("components/messaging/messaging/messaging"); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"same version\n").unwrap(); + (generation, selected) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_runtime() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path()); + let resolved = resolve_managed_with_environment( + MESSAGING, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_refuses_a_same_version_runtime_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path()); + let override_path = root.path().join("other-messaging"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + MESSAGING, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = error; + assert!(diagnostic.contains("MESSAGING_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + assert!(diagnostic.contains("v-test"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_refuses_a_runtime_environment_outside_the_generation() { + let root = tempfile::tempdir().unwrap(); + let (generation, _) = fixture(root.path()); + let outside = root.path().join("outside-messaging"); + fs::write(&outside, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + MESSAGING, + None, + Some(generation.as_os_str()), + Some(outside.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = error; + assert!(diagnostic.contains("RSTACK_MESSAGING_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_names_the_install_command_when_component_is_missing() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let error = resolve_managed_with_environment( + MESSAGING, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = error; + assert!( + diagnostic.contains("rstack install messaging"), + "{diagnostic}" + ); + assert!(diagnostic.contains("will not install"), "{diagnostic}"); + } + + #[test] + fn unmanaged_resolution_preserves_existing_lookup() { + assert_eq!( + resolve_managed_with_environment(MESSAGING, None, None, None, None).unwrap(), + None + ); + } +} diff --git a/crates/registry-messagingctl/tests/serve.rs b/crates/registry-messagingctl/tests/serve.rs new file mode 100644 index 0000000000..e40303a2ba --- /dev/null +++ b/crates/registry-messagingctl/tests/serve.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nif [[ -n \"${{RUNTIME_LEASE_FD:-}}\" ]]; then printf 'lease-inherited\\n' >&\"$RUNTIME_LEASE_FD\"; fi\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_messagingctl")) + .args(arguments) + .env("MESSAGING_BIN", binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("messagingctl starts") +} + +#[test] +fn unmanaged_serve_preserves_runtime_arguments_streams_process_status_and_lease() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "messaging", 17); + let capture = temp.path().join("capture"); + let lease = temp.path().join("lease"); + let mut launcher = Command::new("/bin/bash"); + launcher + .args([ + "-c", + "exec 9>\"$RUNTIME_LEASE_CAPTURE\"; export RUNTIME_LEASE_FD=9; export DYLD_FALLBACK_LIBRARY_PATH=\"${RESTORE_DYLD_FALLBACK_LIBRARY_PATH:-}\"; export DYLD_LIBRARY_PATH=\"${RESTORE_DYLD_LIBRARY_PATH:-}\"; export LD_LIBRARY_PATH=\"${RESTORE_LD_LIBRARY_PATH:-}\"; exec \"$CTL_BIN\" \"$@\"", + "messagingctl-launcher", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ]) + .env("CTL_BIN", env!("CARGO_BIN_EXE_messagingctl")) + .env("MESSAGING_BIN", &binary) + .env("RUNTIME_CAPTURE", &capture) + .env("RUNTIME_LEASE_CAPTURE", &lease) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (source, backup) in [ + ( + "DYLD_FALLBACK_LIBRARY_PATH", + "RESTORE_DYLD_FALLBACK_LIBRARY_PATH", + ), + ("DYLD_LIBRARY_PATH", "RESTORE_DYLD_LIBRARY_PATH"), + ("LD_LIBRARY_PATH", "RESTORE_LD_LIBRARY_PATH"), + ] { + if let Some(value) = std::env::var_os(source) { + launcher.env(backup, value); + } + } + let child = launcher.spawn().expect("messagingctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!( + output.status.code(), + Some(17), + "status={:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!(fs::read_to_string(lease).unwrap(), "lease-inherited\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\nserve\n") + ); +} + +#[test] +fn unmanaged_serve_falls_back_to_path_without_a_binary_override() { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), "messaging", 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_messagingctl")) + .args(["serve", "--runtime-config", "/tmp/path.yaml"]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("MESSAGING_BIN") + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("messagingctl starts"); + + assert_eq!(output.status.code(), Some(21)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml", "serve"] + ); +} + +#[test] +fn managed_serve_uses_the_selected_generation_instead_of_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/messaging/messaging"); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, "messaging", 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + runtime(&poisoned_directory, "messaging", 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_messagingctl")) + .args(["serve", "--runtime-config", "/tmp/managed.yaml"]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_MESSAGING_BIN", &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove("MESSAGING_BIN") + .output() + .expect("messagingctl starts"); + + assert_eq!(output.status.code(), Some(19)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml", "serve"] + ); +} + +#[test] +fn serve_rejects_json_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "messaging", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "--format", + "json", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the runtime must not start"); +} + +#[test] +fn serve_rejects_explicit_human_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "messaging", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "human", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--format does not apply")); + assert!(!capture.exists(), "the runtime must not start"); +} diff --git a/crates/registry-platform-httputil/src/client/exchange_config.rs b/crates/registry-platform-httputil/src/client/exchange_config.rs index 04015422c4..6ee674f1d0 100644 --- a/crates/registry-platform-httputil/src/client/exchange_config.rs +++ b/crates/registry-platform-httputil/src/client/exchange_config.rs @@ -41,10 +41,10 @@ struct KeyClient { assertion_lifetime_seconds: Option, #[serde(alias = "refresh_margin_seconds")] refresh_margin_seconds: Option, - #[serde(alias = "request_timeout_milliseconds")] - request_timeout_milliseconds: Option, - #[serde(alias = "request_timeout_seconds")] - request_timeout_seconds: Option, + #[serde(alias = "attempt_timeout_milliseconds")] + attempt_timeout_milliseconds: Option, + #[serde(alias = "attempt_timeout_seconds")] + attempt_timeout_seconds: Option, #[serde(alias = "connect_timeout_milliseconds")] connect_timeout_milliseconds: Option, #[serde(alias = "connect_timeout_seconds")] @@ -57,7 +57,7 @@ struct KeyClient { impl KeyClient { fn build(self) -> Result { - if self.request_timeout_milliseconds.is_some() && self.request_timeout_seconds.is_some() + if self.attempt_timeout_milliseconds.is_some() && self.attempt_timeout_seconds.is_some() || self.connect_timeout_milliseconds.is_some() && self.connect_timeout_seconds.is_some() { return Err(malformed()); @@ -76,12 +76,12 @@ impl KeyClient { if let Some(value) = self.refresh_margin_seconds { config = config.with_refresh_margin_seconds(value); } - if let Some(value) = self.request_timeout_milliseconds { - config = config.with_request_timeout(Duration::from_millis(value)); + if let Some(value) = self.attempt_timeout_milliseconds { + config = config.with_attempt_timeout(Duration::from_millis(value)); } - if let Some(value) = self.request_timeout_seconds { + if let Some(value) = self.attempt_timeout_seconds { config = config - .with_request_timeout(Duration::try_from_secs_f64(value).map_err(|_| malformed())?); + .with_attempt_timeout(Duration::try_from_secs_f64(value).map_err(|_| malformed())?); } if let Some(value) = self.connect_timeout_milliseconds { config = config.with_connect_timeout(Duration::from_millis(value)); @@ -152,10 +152,10 @@ struct Remote { bootstrap_resource: String, #[serde(alias = "bootstrap_scope")] bootstrap_scope: String, - #[serde(alias = "request_timeout_milliseconds")] - request_timeout_milliseconds: Option, - #[serde(alias = "request_timeout_seconds")] - request_timeout_seconds: Option, + #[serde(alias = "attempt_timeout_milliseconds")] + attempt_timeout_milliseconds: Option, + #[serde(alias = "attempt_timeout_seconds")] + attempt_timeout_seconds: Option, #[serde(alias = "connect_timeout_milliseconds")] connect_timeout_milliseconds: Option, #[serde(alias = "connect_timeout_seconds")] @@ -196,8 +196,8 @@ pub fn exchange_authorization_from_json( ExchangeAuthorization::first_party(client, context, source) } (None, Some(remote)) => { - if remote.request_timeout_milliseconds.is_some() - && remote.request_timeout_seconds.is_some() + if remote.attempt_timeout_milliseconds.is_some() + && remote.attempt_timeout_seconds.is_some() || remote.connect_timeout_milliseconds.is_some() && remote.connect_timeout_seconds.is_some() { @@ -209,9 +209,9 @@ pub fn exchange_authorization_from_json( .trusted_root_certificates .as_deref() .map(str::as_bytes); - let request_timeout = if let Some(value) = remote.request_timeout_milliseconds { + let request_timeout = if let Some(value) = remote.attempt_timeout_milliseconds { Duration::from_millis(value) - } else if let Some(value) = remote.request_timeout_seconds { + } else if let Some(value) = remote.attempt_timeout_seconds { Duration::try_from_secs_f64(value).map_err(|_| malformed())? } else { DEFAULT_REQUEST_TIMEOUT @@ -240,3 +240,41 @@ pub fn exchange_authorization_from_json( _ => Err(malformed()), } } + +#[cfg(test)] +mod tests { + use super::{KeyClient, Remote}; + use serde_json::json; + + #[test] + fn nested_exchange_timeouts_use_the_attempt_name_in_both_binding_languages() { + let base = json!({ + "tokenEndpoint": "https://issuer.example.invalid/token", + "clientId": "client", + "clientKey": {}, + "resource": "urn:registry:resource", + "scopes": [] + }); + for (field, value) in [ + ("attemptTimeoutMilliseconds", json!(3_000)), + ("attempt_timeout_seconds", json!(3.0)), + ] { + let mut authored = base.clone(); + authored[field] = value; + assert!(serde_json::from_value::(authored.clone()).is_ok()); + let mut remote = json!({ + "endpoint": "https://issuer.example.invalid/assertion", + "bootstrap": base.clone(), + "bootstrapResource": "urn:registry:bootstrap", + "bootstrapScope": "bootstrap" + }); + remote[field] = authored[field].clone(); + assert!(serde_json::from_value::(remote).is_ok()); + } + for retired in ["requestTimeoutMilliseconds", "request_timeout_seconds"] { + let mut authored = base.clone(); + authored[retired] = json!(3); + assert!(serde_json::from_value::(authored).is_err()); + } + } +} diff --git a/crates/registry-platform-httputil/src/client/private_key_jwt.rs b/crates/registry-platform-httputil/src/client/private_key_jwt.rs index 3896a6f57b..eba809a8a6 100644 --- a/crates/registry-platform-httputil/src/client/private_key_jwt.rs +++ b/crates/registry-platform-httputil/src/client/private_key_jwt.rs @@ -525,8 +525,9 @@ impl PrivateKeyJwtConfig { self } + /// Bound one outbound request to the token endpoint. #[must_use] - pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + pub fn with_attempt_timeout(mut self, timeout: Duration) -> Self { self.request_timeout = timeout; self } @@ -2980,7 +2981,7 @@ mod tests { endpoint("https://tokens.example.org"), client_key(Some(KEY_ID)), ) - .with_request_timeout(Duration::ZERO), + .with_attempt_timeout(Duration::ZERO), ), ( "the client key cannot sign a client assertion", diff --git a/crates/registry-render/src/server.rs b/crates/registry-render/src/server.rs index 6175c80998..b6f3933647 100644 --- a/crates/registry-render/src/server.rs +++ b/crates/registry-render/src/server.rs @@ -22,6 +22,7 @@ use subtle::ConstantTimeEq; use registry_platform_authcommon::{ parse_bearer_token, validate_api_key_entropy, MIN_API_KEY_ENTROPY_BYTES, }; +use registry_platform_canonical_json::parse_json_preserving_integers; use registry_platform_httpsec::{request_body_limit, security_headers, CspBuilder, Problem}; use crate::audit::{RenderAudit, RenderAuditEvent}; @@ -730,7 +731,10 @@ fn prepare_render( document_type: &str, body: &[u8], ) -> Result<(WorkerRequest, u32), RenderProblem> { - let request: RenderHttpRequest = serde_json::from_slice(body).map_err(|err| { + let value = parse_json_preserving_integers(body).map_err(|err| { + RenderProblem::new(ProblemKind::InvalidArgument, format!("request body: {err}")) + })?; + let request: RenderHttpRequest = serde_json::from_value(value).map_err(|err| { RenderProblem::new(ProblemKind::InvalidArgument, format!("request body: {err}")) })?; let Some(issued_at) = request.issued_at.filter(|s| !s.trim().is_empty()) else { @@ -1110,6 +1114,48 @@ mod tests { assert_eq!(accepted[0]["record"]["problem"], "issued-at-missing"); } + #[tokio::test] + async fn a_duplicate_member_nested_in_data_is_refused_before_rendering() { + let lines = AuditLines::new(None); + let service = service(&lines, 1); + let data = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../products/render/bundles/receipt/fixtures/data.json"), + ) + .expect("receipt fixture"); + let duplicated = data.replacen( + "\"reference\":", + "\"reference\":\"shadowed\",\"reference\":", + 1, + ); + let body = format!(r#"{{"issuedAt":"2026-09-16T10:32:00Z","data":{duplicated}}}"#); + let request = axum::http::Request::builder() + .method(Method::POST) + .uri("/v1/render/receipt") + .header(header::AUTHORIZATION, format!("Bearer {TEST_API_KEY}")) + .header(header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(body)) + .expect("request"); + + let response = router(service).oneshot(request).await.expect("router"); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("problem body"); + let problem: serde_json::Value = serde_json::from_slice(&bytes).expect("JSON problem body"); + assert_eq!( + problem["type"], + "https://render.registrystack.org/problems/invalid-argument" + ); + assert_eq!(problem["title"], "invalid-argument"); + + let accepted = lines.accepted(); + assert_eq!(accepted.len(), 1, "{accepted:?}"); + assert_eq!(accepted[0]["phase"], "response"); + assert_eq!(accepted[0]["record"]["outcome"], "refused"); + assert_eq!(accepted[0]["record"]["problem"], "invalid-argument"); + } + #[tokio::test] async fn a_refusal_after_the_render_starts_keeps_the_render_identity() { let lines = AuditLines::new(None); diff --git a/crates/registry-review-client/src/client.rs b/crates/registry-review-client/src/client.rs index 0636c7b4d5..6f36d8d02f 100644 --- a/crates/registry-review-client/src/client.rs +++ b/crates/registry-review-client/src/client.rs @@ -36,8 +36,8 @@ const MAXIMUM_PROBLEM_BYTES: u64 = 8 * 1024; pub struct ReviewClient { http: reqwest::Client, base_url: ServiceBaseUrl, - max_response_bytes: u64, - request_timeout: Duration, + maximum_response_bytes: u64, + attempt_timeout: Duration, } impl fmt::Debug for ReviewClient { @@ -45,7 +45,7 @@ impl fmt::Debug for ReviewClient { formatter .debug_struct("ReviewClient") .field("base_url", &"") - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .finish_non_exhaustive() } } @@ -54,7 +54,7 @@ impl ReviewClient { pub fn new(config: ReviewClientConfig) -> Result { let base_url = config.validate()?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config.trusted_root_certificates.as_deref(), @@ -63,15 +63,15 @@ impl ReviewClient { Ok(Self { http, base_url, - max_response_bytes: config.max_response_bytes, - request_timeout: config.request_timeout, + maximum_response_bytes: config.maximum_response_bytes, + attempt_timeout: config.attempt_timeout, }) } /// The configured outbound request timeout, so callers that guard an /// exchange with a durable lease can size that lease to outlive it. - pub fn request_timeout(&self) -> Duration { - self.request_timeout + pub fn attempt_timeout(&self) -> Duration { + self.attempt_timeout } /// Create a request, or recover its accepted binding by repeating the exact @@ -440,7 +440,7 @@ impl ReviewClient { status: StatusCode, trace_id: &str, ) -> Result { - let body = read_bounded(response, self.max_response_bytes) + let body = read_bounded(response, self.maximum_response_bytes) .await .map_err(|error| ReviewClientError::Transport { kind: read_failure_kind(&error), diff --git a/crates/registry-review-client/src/config.rs b/crates/registry-review-client/src/config.rs index d778a81151..087a549fb2 100644 --- a/crates/registry-review-client/src/config.rs +++ b/crates/registry-review-client/src/config.rs @@ -14,9 +14,9 @@ pub const MAXIMUM_REVIEW_RESPONSE_BYTES: u64 = 4 * 1024 * 1024; #[derive(Clone)] pub struct ReviewClientConfig { pub(crate) base_url: Url, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>, } @@ -26,17 +26,17 @@ impl ReviewClientConfig { pub fn new(base_url: Url) -> Self { Self { base_url, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_REQUEST_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, - max_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, user_agent: None, trusted_root_certificates: None, } } #[must_use] - pub fn with_request_timeout(mut self, value: Duration) -> Self { - self.request_timeout = value; + pub fn with_attempt_timeout(mut self, value: Duration) -> Self { + self.attempt_timeout = value; self } @@ -47,8 +47,8 @@ impl ReviewClientConfig { } #[must_use] - pub fn with_max_response_bytes(mut self, value: u64) -> Self { - self.max_response_bytes = value; + pub fn with_maximum_response_bytes(mut self, value: u64) -> Self { + self.maximum_response_bytes = value; self } @@ -67,12 +67,14 @@ impl ReviewClientConfig { pub(crate) fn validate(&self) -> Result { let base_url = ServiceBaseUrl::new(self.base_url.clone()) .map_err(|_| ReviewClientError::configuration("the service base URL is not usable"))?; - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(ReviewClientError::configuration( "client timeouts must be greater than zero", )); } - if self.max_response_bytes == 0 || self.max_response_bytes > MAXIMUM_REVIEW_RESPONSE_BYTES { + if self.maximum_response_bytes == 0 + || self.maximum_response_bytes > MAXIMUM_REVIEW_RESPONSE_BYTES + { return Err(ReviewClientError::configuration( "the response body bound is outside the accepted range", )); @@ -95,9 +97,9 @@ impl fmt::Debug for ReviewClientConfig { formatter .debug_struct("ReviewClientConfig") .field("base_url", &"") - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("user_agent_present", &self.user_agent.is_some()) .field( "trusted_root_certificates_present", diff --git a/crates/registry-review-client/tests/http_boundary.rs b/crates/registry-review-client/tests/http_boundary.rs index 057c59973b..177fb00c57 100644 --- a/crates/registry-review-client/tests/http_boundary.rs +++ b/crates/registry-review-client/tests/http_boundary.rs @@ -986,7 +986,7 @@ async fn malformed_media_oversize_and_header_excess_are_refused() { ReviewClientConfig::new( Url::parse(&format!("http://{address}/oversize/")).expect("fixture URL"), ) - .with_max_response_bytes(32), + .with_maximum_response_bytes(32), ) .expect("client"); let error = client diff --git a/crates/registry-rstack/Cargo.toml b/crates/registry-rstack/Cargo.toml new file mode 100644 index 0000000000..ba162df1dc --- /dev/null +++ b/crates/registry-rstack/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "registry-rstack" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Registry Stack component launcher and installer." +repository.workspace = true +publish = false + +[[bin]] +name = "rstack" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +clap.workspace = true +fs2.workspace = true +hex.workspace = true +registry-platform-canonical-json.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +rustix.workspace = true +ureq.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/registry-rstack/README.md b/crates/registry-rstack/README.md new file mode 100644 index 0000000000..ad32b26fd9 --- /dev/null +++ b/crates/registry-rstack/README.md @@ -0,0 +1,95 @@ +# Registry Stack command and local installer + +`rstack` is the public command for a managed local Registry Stack installation. +It selects one release generation and dispatches product commands from that +generation. The managed installation adds only `rstack` to the public executable +directory; service binaries and their images remain independently runnable for +deployment. + +The release-pinned installer is published as `rstack-vX.Y.Z-install.sh`. Download +it and the release verification material from the same release, then follow +[`release/VERIFY.md`](../../release/VERIFY.md) to authenticate the checksum +bundle. For an exact release, run: + +```sh +bash rstack-vX.Y.Z-install.sh +rstack components list +rstack install breg evidence +rstack breg --help +rstack evidence check --help +``` + +The first command installs the launcher alone. Pass component names to the +installer, or use `--all`, to install them during bootstrap. The installable +product names are `breg`, `casework`, `discovery`, `evidence`, `manifest`, +`messaging`, `render`, `scheduling`, and `coordinator`, subject to availability +in the exact release and platform. A selection that mixes available and +unavailable products fails as a whole. A component's release payload includes +its required runtime and companion files, with a capability inventory in the +release manifest. + +The defaults are `~/.local/bin` for the public `rstack` link and +`~/.local/lib/registrystack` for generations. Use `--bin-dir` and +`--install-root` on the installer and on later `rstack` calls if you override +them. To use assets already obtained and verified, pass `--asset-dir DIR` to the +installer or component manager. The installer checks every staged asset against +the release manifest and `SHA256SUMS`; that checksum comparison alone does not +authenticate the publisher. + +```sh +rstack --format json components list +rstack --format json doctor +rstack install casework +rstack update --release vX.Y.Z --preview +rstack update --release vX.Y.Z +rstack uninstall casework +rstack cleanup +rstack uninstall --all +``` + +`--format` is `human` or `json`, and defaults to `human` even when output is +piped. An explicit report format reaches finite product report actions. +Artifact and foreground-service commands reject an inapplicable report format +and keep their output streams unchanged. `--preview` +checks an exact update and reports its component and download changes without +activation. An exact downgrade is refused. Updating or changing components +activates a complete new generation; an invocation already using the previous +one holds a lease until it exits. `cleanup` removes only inactive owned +generations whose leases have ended. `uninstall --all` removes the managed +installation and refuses changed or unrelated paths rather than deleting +them. These operations do not roll back a database or service deployment. + +For scripts, use `--format json` and the report's `schema_version`, `status` +where present, and process exit status. A refusal has one JSON object on stdout +and exits `1` for a domain refusal, `2` for invalid usage, or `3` for an +operational failure. Help is available without an installation: +`rstack help breg`, `rstack breg --help`, and their `--format json` forms use +the offline command catalogue. `rstack doctor` checks the selected generation +and executable path, including whether a different `rstack` shadows it on +`PATH`. + +## Command migration + +| Previous local command | Managed command | +| --- | --- | +| `bregctl` | `rstack breg` | +| `caseworkctl` | `rstack casework` | +| `discoveryctl` | `rstack discovery` | +| `evidencectl` | `rstack evidence` | +| `registry-manifest` | `rstack manifest` | +| `messagingctl` | `rstack messaging` | +| `registry-render` | `rstack render` | +| `schedulingctl` | `rstack scheduling` | +| `coordinatorctl` | `rstack coordinator` | +| `breg-mcp` | `rstack breg mcp` | +| `breg-review` | `rstack breg review` | +| `evidence-oid4vci` | `rstack evidence oid4vci` | + +Scripts that invoke the old executable names must update their commands or +continue using a separately managed binary installation. Product artifact +format and destination flags retain their own meanings; in particular, +Manifest's `--format human|json` selects report encoding on report actions; +`render` uses `--artifact-format` for artifact bytes, and `--output` names +the artifact destination where applicable. The local manager does not rewrite +deployment configuration or install shell completion files. `rstack completions zsh` +prints completion source for an operator to install explicitly. diff --git a/crates/registry-rstack/install.sh b/crates/registry-rstack/install.sh new file mode 100644 index 0000000000..a562482ad6 --- /dev/null +++ b/crates/registry-rstack/install.sh @@ -0,0 +1,287 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository="registrystack/registry-stack" +# Publication replaces this empty value in the release-pinned installer asset. +default_version="" +script_name="${BASH_SOURCE[0]##*/}" +filename_version="" +if [[ "${script_name}" =~ ^rstack-(v[0-9]+\.[0-9]+\.[0-9]+)-install\.sh$ ]]; then + filename_version="${BASH_REMATCH[1]}" +fi +if [[ -n "${default_version}" && -n "${filename_version}" && + "${default_version}" != "${filename_version}" ]]; then + printf 'Installer filename and embedded release disagree.\n' >&2 + exit 2 +fi +default_version="${default_version:-${filename_version}}" + +usage() { + cat <<'EOF' +Install the release-pinned Registry Stack launcher and optional components. + +Usage: bash rstack-install.sh [COMPONENT ... | --all] [options] + bash rstack-install.sh --release vX.Y.Z [COMPONENT ... | --all] + +Options: + --release TAG Exact release for an unpinned source installer. + --bin-dir DIR Public executable directory (default ~/.local/bin). + --install-root DIR Generation storage (default ~/.local/lib/registrystack). + --asset-dir DIR Already downloaded and verified release assets. + --help Show this help. + +With no component selection, install only the launcher. The managed install +exposes only rstack on PATH. No shell profile or completion file is changed. +Downloaded SHA256SUMS checks establish integrity against the fetched manifest; +authenticate the published checksum bundle separately with release/VERIFY.md. +EOF +} + +release="${RSTACK_RELEASE:-${default_version}}" +bin_dir="${HOME:?HOME is required}/.local/bin" +install_root="${HOME:?HOME is required}/.local/lib/registrystack" +asset_dir="" +selected=() +select_all=0 +seen_release=0 +seen_bin_dir=0 +seen_install_root=0 +seen_asset_dir=0 +while [[ "$#" -gt 0 ]]; do + case "$1" in + --release | --bin-dir | --install-root | --asset-dir) + option="$1" + if [[ "$#" -lt 2 || -z "$2" ]]; then + printf '%s needs a value.\n' "${option}" >&2 + exit 2 + fi + case "${option}" in + --release) + [[ "${seen_release}" -eq 0 ]] || { + printf '%s may be supplied once.\n' "${option}" >&2 + exit 2 + } + seen_release=1 + release="$2" + ;; + --bin-dir) + [[ "${seen_bin_dir}" -eq 0 ]] || { + printf '%s may be supplied once.\n' "${option}" >&2 + exit 2 + } + seen_bin_dir=1 + bin_dir="$2" + ;; + --install-root) + [[ "${seen_install_root}" -eq 0 ]] || { + printf '%s may be supplied once.\n' "${option}" >&2 + exit 2 + } + seen_install_root=1 + install_root="$2" + ;; + --asset-dir) + [[ "${seen_asset_dir}" -eq 0 ]] || { + printf '%s may be supplied once.\n' "${option}" >&2 + exit 2 + } + seen_asset_dir=1 + asset_dir="$2" + ;; + esac + shift 2 + ;; + --all) + [[ "${select_all}" -eq 0 ]] || { + printf '%s may be supplied once.\n' "$1" >&2 + exit 2 + } + select_all=1 + shift + ;; + --help | -h) + usage + exit 0 + ;; + --) + shift + selected+=("$@") + break + ;; + -*) + printf 'Unknown option: %s\n' "$1" >&2 + exit 2 + ;; + *) + selected+=("$1") + shift + ;; + esac +done +if [[ "${select_all}" -eq 1 && "${#selected[@]}" -gt 0 ]]; then + printf 'Choose component names or --all, not both.\n' >&2 + exit 2 +fi +if [[ -z "${release}" || ! "${release}" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + printf 'An exact --release vX.Y.Z is required for an unpinned installer.\n' >&2 + exit 2 +fi +if [[ -n "${default_version}" && "${release}" != "${default_version}" ]]; then + printf 'Published installer is pinned to %s; release override refused.\n' "${default_version}" >&2 + exit 2 +fi +if [[ "${seen_release}" -eq 1 && -n "${RSTACK_RELEASE:-}" && "${RSTACK_RELEASE}" != "${release}" ]]; then + printf -- '--release conflicts with RSTACK_RELEASE.\n' >&2 + exit 2 +fi + +case "$(uname -s):$(uname -m)" in +Linux:x86_64) platform=linux-amd64 ;; +Linux:aarch64 | Linux:arm64) platform=linux-arm64 ;; +Darwin:arm64) platform=macos-arm64 ;; +*) + printf 'No rstack launcher is published for this platform.\n' >&2 + exit 2 + ;; +esac + +launcher_name="rstack-${release}-${platform}" +if [[ "${platform}" == macos-arm64 ]]; then + launcher_name+=".tar.gz" +fi +manifest_name="registry-stack-${release}-release-manifest.json" +scratch="$(mktemp -d "${TMPDIR:-/tmp}/rstack-bootstrap.XXXXXXXX")" +cleanup() { + if [[ -d "${scratch}" && ! -L "${scratch}" ]]; then + rm -r -- "${scratch}" + fi +} +trap cleanup EXIT + +fetch_asset() { + local name="$1" + if [[ -n "${asset_dir}" ]]; then + if [[ ! -f "${asset_dir}/${name}" ]]; then + printf 'Verified asset directory lacks %s.\n' "${name}" >&2 + exit 3 + fi + cp "${asset_dir}/${name}" "${scratch}/${name}" + else + if ! command -v curl >/dev/null 2>&1; then + printf 'Downloading release assets requires curl.\n' >&2 + exit 3 + fi + curl --fail --silent --show-error --location --proto '=https' \ + --proto-redir '=https' --max-time 300 \ + --output "${scratch}/${name}" \ + "https://github.com/${repository}/releases/download/${release}/${name}" + fi +} + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + printf 'A SHA-256 tool (shasum or sha256sum) is required.\n' >&2 + exit 3 + fi +} + +expected_hash() { + awk -v wanted="$1" ' + $2 == wanted { + if (seen++ || length($1) != 64 || $1 !~ /^[0-9a-f]+$/) bad = 1 + value = $1 + } + END { if (bad || seen != 1) exit 1; print value } + ' "${scratch}/SHA256SUMS" +} + +fetch_asset SHA256SUMS +for name in "${manifest_name}" "${launcher_name}"; do + fetch_asset "${name}" + if ! expected="$(expected_hash "${name}")"; then + printf 'SHA256SUMS has no unique digest for %s.\n' "${name}" >&2 + exit 3 + fi + if [[ "$(sha256_file "${scratch}/${name}")" != "${expected}" ]]; then + printf 'Release asset checksum mismatch: %s.\n' "${name}" >&2 + exit 3 + fi +done + +launcher="${scratch}/${launcher_name}" +if [[ "${platform}" == macos-arm64 ]]; then + mkdir "${scratch}/extracted" + if ! entries="$(tar -tzf "${launcher}")" || + ! listing="$(tar -tvzf "${launcher}")"; then + printf 'Launcher archive cannot be inspected.\n' >&2 + exit 3 + fi + entry_count=0 + has_launcher=0 + has_notices=0 + has_fips_module=0 + seen_entries=$'\n' + while IFS= read -r entry; do + case "${entry}" in + "${launcher_name%.tar.gz}") has_launcher=1 ;; + THIRD_PARTY_NOTICES) has_notices=1 ;; + libaws_lc_fips_*.dylib) has_fips_module=1 ;; + *) + printf 'Launcher archive has an unsupported entry.\n' >&2 + exit 3 + ;; + esac + if [[ "${seen_entries}" == *$'\n'"${entry}"$'\n'* ]]; then + printf 'Launcher archive has a duplicate entry.\n' >&2 + exit 3 + fi + seen_entries+="${entry}"$'\n' + entry_count=$((entry_count + 1)) + done <<<"${entries}" + if [[ "${has_launcher}" -ne 1 || "${has_notices}" -ne 1 || + "${has_fips_module}" -ne 1 ]]; then + printf 'Launcher archive is incomplete.\n' >&2 + exit 3 + fi + listed_count=0 + while IFS= read -r item; do + if [[ "${item:0:1}" != "-" ]]; then + printf 'Launcher archive contains a nonregular entry.\n' >&2 + exit 3 + fi + listed_count=$((listed_count + 1)) + done <<<"${listing}" + if [[ "${listed_count}" -ne "${entry_count}" ]]; then + printf 'Launcher archive listing changed during inspection.\n' >&2 + exit 3 + fi + tar -xzf "${launcher}" -C "${scratch}/extracted" + launcher="${scratch}/extracted/${launcher_name%.tar.gz}" + if [[ ! -f "${launcher}" || -L "${launcher}" ]]; then + printf 'Launcher archive has no regular executable.\n' >&2 + exit 3 + fi +fi +chmod 0700 "${launcher}" +if [[ "$("${launcher}" --version)" != "rstack ${release#v}" ]]; then + printf 'Launcher executable reports the wrong release.\n' >&2 + exit 3 +fi + +arguments=(bootstrap --release-manifest "${scratch}/${manifest_name}" + --checksum-file "${scratch}/SHA256SUMS" + --launcher-asset "${scratch}/${launcher_name}" + --bin-dir "${bin_dir}" --install-root "${install_root}") +if [[ -n "${asset_dir}" ]]; then + arguments+=(--asset-dir "${asset_dir}") +fi +if [[ "${select_all}" -eq 1 ]]; then + arguments+=(--all) +else + arguments+=("${selected[@]}") +fi +"${launcher}" "${arguments[@]}" diff --git a/crates/registry-rstack/src/assets.rs b/crates/registry-rstack/src/assets.rs new file mode 100644 index 0000000000..44357f6bac --- /dev/null +++ b/crates/registry-rstack/src/assets.rs @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Release-asset acquisition and integrity checks shared by bootstrap and manager. + +use crate::Asset; +use sha2::{Digest, Sha256}; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const MAX_METADATA_BYTES: usize = 16 * 1024 * 1024; + +pub struct AssetProvider { + release: String, + asset_dir: Option, +} + +impl AssetProvider { + pub fn new(release: &str, asset_dir: Option) -> Result { + let version = release + .strip_prefix('v') + .ok_or("release must start with v")?; + let parts: Vec<&str> = version.split('.').collect(); + if parts.len() != 3 + || parts.iter().any(|part| { + part.is_empty() + || (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|byte| byte.is_ascii_digit()) + || part.parse::().is_err() + }) + { + return Err("release must be an exact canonical vX.Y.Z tag".into()); + } + Ok(Self { + release: release.to_owned(), + asset_dir, + }) + } + + /// Fetch a small manifest or checksum list before its digest is known. + /// Callers must verify it against the expected checksum before using it. + pub fn fetch_metadata(&self, name: &str) -> Result, String> { + safe_name(name)?; + let mut reader = self.open(name)?; + let mut bytes = Vec::new(); + reader + .by_ref() + .take((MAX_METADATA_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|error| format!("cannot read {name}: {error}"))?; + if bytes.len() > MAX_METADATA_BYTES { + return Err(format!("release metadata {name} exceeds 16 MiB")); + } + Ok(bytes) + } + + /// Create an asset in a staging directory and make it usable only after + /// both its exact length and SHA-256 match the release manifest. + pub fn fetch(&self, asset: &Asset, destination: &Path) -> Result<(), String> { + asset.validate()?; + let mut reader = self.open(&asset.name)?; + let mut writer = OpenOptions::new() + .write(true) + .create_new(true) + .open(destination) + .map_err(|error| format!("cannot stage {}: {error}", asset.name))?; + let result = (|| { + let mut hash = Sha256::new(); + let mut total = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|error| format!("cannot read {}: {error}", asset.name))?; + if count == 0 { + break; + } + total = total + .checked_add(count as u64) + .ok_or("asset size overflow")?; + if total > asset.size { + return Err(format!("asset {} exceeds published size", asset.name)); + } + hash.update(&buffer[..count]); + writer + .write_all(&buffer[..count]) + .map_err(|error| format!("cannot stage {}: {error}", asset.name))?; + } + if total != asset.size || hex::encode(hash.finalize()) != asset.sha256 { + return Err(format!( + "asset {} does not match published digest and size", + asset.name + )); + } + writer + .sync_all() + .map_err(|error| format!("cannot sync {}: {error}", asset.name))?; + Ok(()) + })(); + match result { + Ok(()) => Ok(()), + Err(error) => { + drop(writer); + match fs::remove_file(destination) { + Ok(()) => Err(error), + Err(cleanup) => Err(format!( + "{error}; cannot remove incomplete staging file {}: {cleanup}", + destination.display() + )), + } + } + } + } + + fn open(&self, name: &str) -> Result, String> { + safe_name(name)?; + if let Some(directory) = &self.asset_dir { + let path = directory.join(name); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("cannot inspect asset {name}: {error}"))?; + if !metadata.file_type().is_file() { + return Err(format!("asset {name} must be a regular file")); + } + return File::open(&path) + .map(|file| Box::new(file) as Box) + .map_err(|error| format!("cannot open asset {name}: {error}")); + } + let url = format!( + "https://github.com/registrystack/registry-stack/releases/download/{}/{name}", + self.release + ); + let config = ureq::Agent::config_builder() + .timeout_connect(Some(Duration::from_secs(15))) + .timeout_global(Some(Duration::from_secs(300))) + .max_redirects(5) + .build(); + let agent: ureq::Agent = config.into(); + let response = agent + .get(&url) + .call() + .map_err(|error| format!("cannot fetch release asset {name}: {error}"))?; + Ok(Box::new(response.into_body().into_reader())) + } +} + +pub fn checksum_for(raw: &[u8], wanted: &str) -> Result { + safe_name(wanted)?; + let text = std::str::from_utf8(raw).map_err(|_| "SHA256SUMS is not UTF-8")?; + let mut found = None; + for line in text.lines() { + let (digest, name) = line + .split_once(" ") + .ok_or("SHA256SUMS has a malformed line")?; + if digest.len() != 64 + || !digest + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err("SHA256SUMS has an invalid SHA-256 digest".into()); + } + safe_name(name)?; + if name == wanted && found.replace(digest.to_owned()).is_some() { + return Err(format!("SHA256SUMS repeats {wanted}")); + } + } + found.ok_or_else(|| format!("SHA256SUMS omits {wanted}")) +} + +pub fn verify_bytes(name: &str, bytes: &[u8], expected: &str) -> Result<(), String> { + let observed = hex::encode(Sha256::digest(bytes)); + if observed != expected { + return Err(format!("release asset checksum mismatch: {name}")); + } + Ok(()) +} + +fn safe_name(name: &str) -> Result<(), String> { + if name.is_empty() + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + || name.contains('\0') + { + return Err("asset name must be a safe basename".into()); + } + Ok(()) +} diff --git a/crates/registry-rstack/src/cli.rs b/crates/registry-rstack/src/cli.rs new file mode 100644 index 0000000000..3099d58932 --- /dev/null +++ b/crates/registry-rstack/src/cli.rs @@ -0,0 +1,1122 @@ +// SPDX-License-Identifier: Apache-2.0 + +use crate::install::{self, BootstrapOptions}; +use crate::store::{GenerationStore, InvocationLease}; +use crate::ReleaseManifest; +use clap::{ArgAction, CommandFactory, Parser, Subcommand, ValueEnum}; +use serde_json::{json, Value}; +use std::ffi::OsString; +use std::fs; +use std::io::{self, Write}; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::Command as ProcessCommand; + +const PRODUCTS: [&str; 9] = [ + "breg", + "casework", + "discovery", + "evidence", + "manifest", + "messaging", + "render", + "scheduling", + "coordinator", +]; + +macro_rules! reportln { + ($($argument:tt)*) => { + write_stdout(format_args!($($argument)*))? + }; +} + +fn write_stdout(arguments: std::fmt::Arguments<'_>) -> Result<(), CliFailure> { + let mut stdout = io::stdout().lock(); + stdout + .write_fmt(arguments) + .and_then(|_| stdout.write_all(b"\n")) + .and_then(|_| stdout.flush()) + .map_err(|error| CliFailure::operational(format!("cannot write report: {error}"))) +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ReportFormat { + Human, + Json, +} + +#[derive(Parser)] +#[command( + name = "rstack", + version, + disable_help_subcommand = true, + about = "Registry Stack command and component manager" +)] +struct Cli { + /// Report encoding. Defaults to human even when stdout is piped. + #[arg(long, global = true, value_enum, action = ArgAction::Append, default_value = "human")] + format: Vec, + + /// Installation generation storage directory. + #[arg(long, global = true)] + install_root: Option, + + /// Directory for the single public rstack executable link. + #[arg(long, global = true)] + bin_dir: Option, + + /// Directory of already downloaded, verified release assets. + #[arg(long, global = true)] + asset_dir: Option, + + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Command { + /// Activate a release-pinned launcher and optional components. + #[command(hide = true)] + Bootstrap { + #[arg(long)] + release_manifest: PathBuf, + #[arg(long)] + checksum_file: PathBuf, + #[arg(long)] + launcher_asset: PathBuf, + #[arg(long)] + all: bool, + #[arg(value_name = "COMPONENT")] + components: Vec, + }, + /// List the selected release and component availability. + Components { + #[command(subcommand)] + command: ComponentCommand, + }, + /// Install one or more components at the selected release. + Install { + /// Install every product available for the selected platform. + #[arg(long)] + all: bool, + /// Product to install. + #[arg(value_name = "COMPONENT")] + components: Vec, + }, + /// Update the launcher and installed components to one exact release. + Update { + /// Exact release tag to activate. + #[arg(long)] + release: String, + /// Check the target release and report changes without activation. + #[arg(long)] + preview: bool, + }, + /// Remove one or more installed components. + Uninstall { + /// Remove the complete managed installation. + #[arg(long)] + all: bool, + /// Product to remove. + #[arg(value_name = "COMPONENT")] + components: Vec, + }, + /// Remove inactive, unused installation generations. + Cleanup, + /// Check selected binaries, paths, and component availability. + Doctor, + /// Show offline product help. + Help { + /// Product whose offline command reference to show. + #[arg(value_name = "PRODUCT")] + product: Option, + }, + /// Generate shell completion script. + Completions { + /// Shell whose completion script to generate. + #[arg(value_name = "SHELL")] + shell: String, + }, + #[command(external_subcommand)] + Product(Vec), +} + +#[derive(Subcommand)] +enum ComponentCommand { + /// List every known product and its selected-release status. + List, +} + +pub fn command() -> clap::Command { + Cli::command() +} + +pub fn run() -> i32 { + let arguments: Vec = std::env::args_os().collect(); + let (duplicate_format, json_requested, format_supplied) = report_arguments(&arguments); + if duplicate_format { + return emit_failure(2, "--format may be supplied only once", json_requested); + } + let cli = match Cli::try_parse_from(arguments) { + Ok(cli) => cli, + Err(error) => { + let code = error.exit_code(); + if code != 0 && json_requested { + return emit_failure(code, &error.to_string(), true); + } + let _ = error.print(); + return code; + } + }; + if let Err(error) = execute(cli, format_supplied) { + if error.reported { + return error.code; + } + return emit_failure(error.code, &error.message, json_requested); + } + 0 +} + +fn report_arguments(arguments: &[OsString]) -> (bool, bool, bool) { + let mut occurrences = 0; + let mut json = false; + let mut values = arguments.iter().skip(1); + while let Some(argument) = values.next() { + if argument == "--" { + break; + } + if argument == "--format" { + occurrences += 1; + json |= values.next().is_some_and(|value| value == "json"); + } else if let Some(value) = argument + .to_str() + .and_then(|text| text.strip_prefix("--format=")) + { + occurrences += 1; + json |= value == "json"; + } + } + (occurrences > 1, json, occurrences > 0) +} + +fn emit_failure(code: i32, message: &str, json: bool) -> i32 { + if json { + let category = match code { + 1 => "domain", + 2 => "usage", + _ => "operational", + }; + let report = json!({ + "schema_version":"registry.rstack.error.v1", + "status":"refused", + "error":{"class":category,"message":message} + }); + if write_stdout(format_args!("{report}")).is_err() { + let _ = writeln!(io::stderr().lock(), "rstack: cannot write JSON report"); + return 3; + } + } else { + let _ = writeln!(io::stderr().lock(), "rstack: {message}"); + } + code +} + +struct CliFailure { + code: i32, + message: String, + reported: bool, +} + +impl CliFailure { + fn domain(message: String) -> Self { + Self { + code: 1, + message, + reported: false, + } + } + + fn operational(message: String) -> Self { + Self { + code: 3, + message, + reported: false, + } + } + + fn reported(code: i32) -> Self { + Self { + code, + message: String::new(), + reported: true, + } + } +} + +impl From for CliFailure { + fn from(message: String) -> Self { + Self { + code: 2, + message, + reported: false, + } + } +} + +impl From<&str> for CliFailure { + fn from(message: &str) -> Self { + Self::from(message.to_owned()) + } +} + +fn execute(cli: Cli, format_supplied: bool) -> Result<(), CliFailure> { + if cli.format.len() > 1 { + return Err("--format may be supplied only once".into()); + } + let format = cli.format.first().copied().unwrap_or(ReportFormat::Human); + match &cli.command { + Some(Command::Bootstrap { + release_manifest, + checksum_file, + launcher_asset, + all, + components, + }) => { + let report = install::bootstrap(BootstrapOptions { + release_manifest: release_manifest.clone(), + checksum_file: checksum_file.clone(), + launcher_asset: launcher_asset.clone(), + asset_dir: cli.asset_dir.clone(), + install_root: match &cli.install_root { + Some(path) => path.clone(), + None => default_install_root()?, + }, + bin_dir: match &cli.bin_dir { + Some(path) => path.clone(), + None => default_bin_dir()?, + }, + components: components.clone(), + all: *all, + }) + .map_err(CliFailure::operational)?; + match format { + ReportFormat::Human => reportln!( + "Activated {} with {} component(s) in {}", + report.release, + report.components.len(), + report.generation + ), + ReportFormat::Json => reportln!( + "{}", + serde_json::to_string(&report) + .map_err(|error| CliFailure::operational(error.to_string()))? + ), + } + } + None | Some(Command::Help { product: None }) => match format { + ReportFormat::Human => { + reportln!("rstack [options]"); + reportln!("Use rstack help for offline product help."); + reportln!("Use rstack components list to inspect installation availability."); + reportln!("Products: {}", PRODUCTS.join(", ")); + } + ReportFormat::Json => reportln!( + "{}", + json!({"schema_version":"registry.rstack.help.v1","command":{"name":"rstack","invocation":"rstack","products":PRODUCTS}}) + ), + }, + Some(Command::Help { + product: Some(product), + }) => { + known_product(product)?; + product_help(product, format, &[])?; + } + Some(Command::Components { + command: ComponentCommand::List, + }) => list_components(&cli, format)?, + Some(Command::Install { all, components }) => { + if (*all && !components.is_empty()) || (!*all && components.is_empty()) { + return Err("select component names or --all, but not both".into()); + } + for component in components { + known_product(component)?; + } + let report = install::install(manager_options(&cli)?, components.clone(), *all) + .map_err(manager_failure)?; + match format { + ReportFormat::Human => reportln!( + "Activated {} with components: {}", + report.release, + report.components.join(", ") + ), + ReportFormat::Json => reportln!( + "{}", + serde_json::to_string(&report) + .map_err(|error| CliFailure::operational(error.to_string()))? + ), + } + } + Some(Command::Uninstall { all, components }) => { + if (*all && !components.is_empty()) || (!*all && components.is_empty()) { + return Err("select component names or --all, but not both".into()); + } + for component in components { + known_product(component)?; + } + if *all { + let report = + install::uninstall_all(manager_options(&cli)?).map_err(manager_failure)?; + match format { + ReportFormat::Human => reportln!("Removed Registry Stack installation"), + ReportFormat::Json => reportln!( + "{}", + serde_json::to_string(&report) + .map_err(|error| CliFailure::operational(error.to_string()))? + ), + } + } else { + let report = install::uninstall(manager_options(&cli)?, components.clone()) + .map_err(manager_failure)?; + match format { + ReportFormat::Human => reportln!( + "Activated {} without: {}", + report.release, + report.removed.join(", ") + ), + ReportFormat::Json => reportln!( + "{}", + serde_json::to_string(&report) + .map_err(|error| CliFailure::operational(error.to_string()))? + ), + } + } + } + Some(Command::Cleanup) => { + let (install_root, bin_dir) = installation_paths(&cli)?; + let store = GenerationStore::new(install_root, bin_dir); + if !store.has_installation().map_err(CliFailure::operational)? { + return Err(CliFailure::domain( + "no Registry Stack installation is selected".into(), + )); + } + let mutation = store.lock_mutation().map_err(CliFailure::operational)?; + let report = mutation.cleanup().map_err(CliFailure::operational)?; + match format { + ReportFormat::Human => reportln!( + "Removed {} inactive generation(s); preserved {}", + report.removed.len(), + report.preserved.len() + ), + ReportFormat::Json => reportln!( + "{}", + json!({ + "schema_version":"registry.rstack.cleanup.v1", + "removed":report.removed, + "preserved":report.preserved.iter().map(|item| json!({"generation":item.name,"reason":format!("{:?}",item.reason).to_ascii_lowercase()})).collect::>() + }) + ), + } + } + Some(Command::Doctor) => doctor(&cli, format)?, + Some(Command::Update { release, preview }) => { + let report = install::update(&manager_options(&cli)?, release, *preview) + .map_err(manager_failure)?; + match format { + ReportFormat::Human => reportln!( + "{} {} to {} with {} component(s), {} bytes to download{}", + if report.preview { + "Preview update from" + } else if report.activated { + "Updated from" + } else { + "Already at" + }, + report.current_release, + report.target_release, + report.components.len(), + report.download_bytes, + if report.added_components.is_empty() { + String::new() + } else { + format!("; added: {}", report.added_components.join(", ")) + } + ), + ReportFormat::Json => reportln!( + "{}", + serde_json::to_string(&report) + .map_err(|error| CliFailure::operational(error.to_string()))? + ), + } + } + Some(Command::Completions { shell }) => { + if format_supplied { + return Err("--format is not supported with completions".into()); + } + if shell != "zsh" { + return Err(format!("unsupported shell {shell}; supported: zsh").into()); + } + reportln!( + "#compdef rstack\n_arguments '1:product:({})'", + PRODUCTS.join(" ") + ); + } + Some(Command::Product(arguments)) => { + let product = arguments + .first() + .and_then(|value| value.to_str()) + .ok_or("product name must be UTF-8")?; + known_product(product)?; + if let Some((help_format, path)) = product_help_request(arguments, format)? { + product_help(product, help_format, &path)?; + } else { + dispatch_product(&cli, product, &arguments[1..], format_supplied)?; + } + } + } + Ok(()) +} + +fn product_help_request( + arguments: &[OsString], + default_format: ReportFormat, +) -> Result)>, CliFailure> { + let mut format = default_format; + let mut path = Vec::new(); + let mut help = false; + let mut values = arguments.iter().skip(1); + while let Some(value) = values.next() { + if value == "--" { + if help { + path.push("--".to_owned()); + path.extend(values.filter_map(|value| value.to_str().map(str::to_owned))); + } + break; + } + if value == "--help" { + help = true; + } else if value == "--format" { + let value = values.next().ok_or("--format needs human or json")?; + format = parse_report_format(value)?; + } else if let Some(value) = value + .to_str() + .and_then(|text| text.strip_prefix("--format=")) + { + format = parse_report_format(&OsString::from(value))?; + } else if let Some(value) = value.to_str() { + path.push(value.to_owned()); + } + } + Ok(help.then_some((format, path))) +} + +fn parse_report_format(value: &OsString) -> Result { + match value.to_str() { + Some("human") => Ok(ReportFormat::Human), + Some("json") => Ok(ReportFormat::Json), + _ => Err("--format must be human or json".into()), + } +} + +fn product_help(product: &str, format: ReportFormat, path: &[String]) -> Result<(), CliFailure> { + let entrypoint = match product { + "breg" => "bregctl", + "casework" => "caseworkctl", + "discovery" => "discoveryctl", + "evidence" => "evidencectl", + "manifest" => "manifest", + "messaging" => "messagingctl", + "render" => "registry-render", + "scheduling" => "schedulingctl", + "coordinator" => "coordinatorctl", + _ => return Err(format!("unknown component {product}").into()), + }; + let catalog: Value = serde_json::from_str(include_str!("../../registry-cli-docs/catalog.json")) + .map_err(|error| format!("offline command catalogue is invalid: {error}"))?; + let mut reference = catalog["binaries"] + .as_array() + .and_then(|commands| { + commands + .iter() + .find(|command| command["name"] == entrypoint) + }) + .cloned() + .ok_or_else(|| format!("offline command catalogue lacks {product}"))?; + rewrite_reference(&mut reference, entrypoint, &format!("rstack {product}")); + reference["name"] = Value::String(product.to_owned()); + for token in path { + if token.starts_with('-') { + return Err(format!("unknown offline help option {token}").into()); + } + let Some(child) = reference["subcommands"] + .as_array() + .and_then(|commands| { + commands + .iter() + .find(|command| command["name"].as_str() == Some(token.as_str())) + }) + .cloned() + else { + return Err( + format!("unknown or surplus action {token} for offline {product} help").into(), + ); + }; + reference = child; + } + match format { + ReportFormat::Human => { + reportln!( + "{}: {}", + reference["invocation"].as_str().unwrap_or("rstack"), + reference["about"].as_str().unwrap_or("") + ); + if let Some(actions) = reference["subcommands"].as_array() { + for action in actions { + if let Some(name) = action["name"].as_str() { + reportln!(" {name}"); + } + } + } + if let Some(options) = reference["options"].as_array() { + for option in options { + if let Some(display) = option["display"].as_str() { + reportln!(" {display}"); + } + } + } + } + ReportFormat::Json => reportln!( + "{}", + json!({"schema_version":"registry.rstack.help.v1","command":reference}) + ), + } + Ok(()) +} + +fn rewrite_reference(value: &mut Value, private: &str, public: &str) { + match value { + Value::String(text) if private != "manifest" && text.contains(private) => { + *text = text.replace(private, public); + } + Value::Array(items) => { + for item in items { + rewrite_reference(item, private, public); + } + } + Value::Object(fields) => { + for (name, item) in fields.iter_mut() { + if private == "manifest" && matches!(name.as_str(), "invocation" | "usage") { + if let Value::String(text) = item { + if name == "invocation" { + if let Some(rest) = text.strip_prefix("manifest") { + if rest.is_empty() || rest.starts_with(' ') { + *text = format!("{public}{rest}"); + } + } + } else { + *text = text.replace("Usage: manifest", &format!("Usage: {public}")); + } + } + } + rewrite_reference(item, private, public); + } + } + _ => {} + } +} + +fn known_product(product: &str) -> Result<(), String> { + if PRODUCTS.contains(&product) { + Ok(()) + } else { + Err(format!("unknown component {product}")) + } +} + +fn home_directory() -> Result { + std::env::var_os("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .ok_or_else(|| { + CliFailure::operational("HOME is required for the default installation paths".into()) + }) +} + +fn default_install_root() -> Result { + Ok(home_directory()?.join(".local/lib/registrystack")) +} + +fn default_bin_dir() -> Result { + Ok(home_directory()?.join(".local/bin")) +} + +fn installation_paths(cli: &Cli) -> Result<(PathBuf, PathBuf), CliFailure> { + let install_root = match &cli.install_root { + Some(path) => path.clone(), + None => default_install_root()?, + }; + let bin_dir = match &cli.bin_dir { + Some(path) => path.clone(), + None => default_bin_dir()?, + }; + Ok((install_root, bin_dir)) +} + +fn manager_options(cli: &Cli) -> Result { + let (install_root, bin_dir) = installation_paths(cli)?; + Ok(install::ManagerOptions { + install_root, + bin_dir, + asset_dir: cli.asset_dir.clone(), + }) +} + +fn manager_failure(error: install::ManagerError) -> CliFailure { + match error.class { + install::ManagerErrorClass::Domain => CliFailure::domain(error.message), + install::ManagerErrorClass::Operational => CliFailure::operational(error.message), + } +} + +fn selected_state( + cli: &Cli, +) -> Result< + Option<( + InvocationLease, + install::InstalledSelection, + ReleaseManifest, + )>, + CliFailure, +> { + let (install_root, bin_dir) = installation_paths(cli)?; + let store = GenerationStore::new(install_root, bin_dir); + if !store.has_installation().map_err(CliFailure::operational)? { + return Ok(None); + } + let lease = store.acquire_current().map_err(CliFailure::operational)?; + let selection = install::read_selection(lease.path()).map_err(CliFailure::operational)?; + let manifest_path = lease.path().join("manifest.json"); + let metadata = fs::metadata(&manifest_path).map_err(|error| { + CliFailure::operational(format!("cannot inspect selected release: {error}")) + })?; + if metadata.len() > 16 * 1024 * 1024 { + return Err(CliFailure::operational( + "selected release manifest exceeds 16 MiB".into(), + )); + } + let raw = fs::read(&manifest_path).map_err(|error| { + CliFailure::operational(format!("cannot read selected release: {error}")) + })?; + let manifest = ReleaseManifest::parse(&raw).map_err(CliFailure::operational)?; + if manifest.release.tag != selection.release || manifest.components.release != selection.release + { + return Err(CliFailure::operational( + "selected release identity differs from installed component selection".into(), + )); + } + Ok(Some((lease, selection, manifest))) +} + +fn list_components(cli: &Cli, format: ReportFormat) -> Result<(), CliFailure> { + let selected = selected_state(cli)?; + let Some((lease, selection, manifest)) = selected else { + let components: Vec = PRODUCTS + .iter() + .map(|name| { + json!({ + "name":name, + "installed":false, + "available":null, + "version":null, + "entrypoint":null, + "path":null, + "capabilities":null + }) + }) + .collect(); + match format { + ReportFormat::Human => { + reportln!("No installation selected."); + for name in PRODUCTS { + reportln!(" {name}: availability unknown, version unavailable, path none"); + } + } + ReportFormat::Json => reportln!( + "{}", + json!({"schema_version":"registry.rstack.components.v1","release":null,"generation":null,"components":components}) + ), + } + return Ok(()); + }; + let components: Vec = PRODUCTS + .iter() + .map(|name| { + let component = manifest.components.components.get(*name); + let payload = + component.and_then(|component| component.platforms.get(&selection.platform)); + let available = payload.is_some(); + let installed = available + && selection + .components + .iter() + .any(|selected| selected.as_str() == *name); + let path = installed.then(|| { + let entrypoint = &component.expect("available component exists").entrypoint; + lease + .path() + .join("components") + .join(*name) + .join(entrypoint) + .join(entrypoint) + }); + json!({ + "name":name, + "installed":installed, + "available":available, + "version":available.then_some(&manifest.release.version), + "entrypoint":component.map(|component| &component.entrypoint), + "path":path, + "capabilities":payload.map(|payload| &payload.capabilities) + }) + }) + .collect(); + match format { + ReportFormat::Human => { + reportln!( + "Selected release: {} ({})", + selection.release, + lease.generation().name() + ); + for item in &components { + let name = item["name"].as_str().unwrap_or("unknown"); + let state = if item["installed"] == true { + "installed" + } else if item["available"] == true { + "available" + } else { + "unavailable" + }; + let version = item["version"].as_str().unwrap_or("unavailable"); + let path = item["path"].as_str().unwrap_or("none"); + reportln!(" {name}: {state}, version {version}, path {path}"); + } + } + ReportFormat::Json => reportln!( + "{}", + json!({"schema_version":"registry.rstack.components.v1","release":selection.release,"generation":lease.generation().name(),"platform":selection.platform,"components":components}) + ), + } + Ok(()) +} + +fn doctor(cli: &Cli, format: ReportFormat) -> Result<(), CliFailure> { + let (install_root, bin_dir) = installation_paths(cli)?; + let store = GenerationStore::new(&install_root, &bin_dir); + let mut problems = Vec::new(); + let selected = match selected_state(cli) { + Ok(selected) => selected, + Err(error) => { + problems.push(error.message); + None + } + }; + if selected.is_none() && problems.is_empty() { + problems.push("no Registry Stack installation is selected".into()); + } + let expected_link = store + .public_link_target() + .map_err(CliFailure::operational)?; + let public_link = store.public_link(); + match fs::symlink_metadata(&public_link) { + Ok(metadata) if metadata.file_type().is_symlink() => match fs::read_link(&public_link) { + Ok(target) if target == expected_link => {} + Ok(_) => problems.push("public rstack path is not owned by this installation".into()), + Err(error) => problems.push(format!("cannot read public rstack link: {error}")), + }, + Ok(_) => problems.push("public rstack path is occupied by another file".into()), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + problems.push("public rstack link is missing".into()) + } + Err(error) => problems.push(format!("cannot inspect public rstack path: {error}")), + } + if let Some(first) = first_path_rstack() { + if fs::canonicalize(&first).ok() != fs::canonicalize(&public_link).ok() { + problems.push(format!( + "another rstack shadows the selected launcher at {}", + first.display() + )); + } + } + let release = selected + .as_ref() + .map(|(_, selection, _)| &selection.release); + let generation = selected + .as_ref() + .map(|(lease, _, _)| lease.generation().name()); + let components = selected + .as_ref() + .map(|(_, selection, _)| &selection.components); + let ok = problems.is_empty(); + let report = json!({ + "schema_version":"registry.rstack.doctor.v1", + "status":if ok {"ok"} else {"refused"}, + "release":release, + "generation":generation, + "paths":{"install_root":install_root,"bin_dir":bin_dir,"public_link":public_link}, + "components":components, + "problems":problems, + }); + match format { + ReportFormat::Human => { + if ok { + reportln!( + "Registry Stack installation is healthy: {}", + release.unwrap() + ); + } else { + for problem in &problems { + writeln!(io::stderr().lock(), "rstack: {problem}").map_err(|error| { + CliFailure::operational(format!("cannot write diagnosis: {error}")) + })?; + } + } + } + ReportFormat::Json => { + write_stdout(format_args!("{report}"))?; + } + } + if ok { + Ok(()) + } else { + Err(CliFailure::reported(1)) + } +} + +fn first_path_rstack() -> Option { + let path = std::env::var_os("PATH")?; + for directory in std::env::split_paths(&path) { + let candidate = directory.join("rstack"); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn dispatch_product( + cli: &Cli, + product: &str, + arguments: &[OsString], + format_supplied: bool, +) -> Result<(), CliFailure> { + let Some((lease, selection, manifest)) = selected_state(cli)? else { + return Err(CliFailure::domain(format!( + "component {product} is missing; run rstack install {product}" + ))); + }; + if !selection.components.iter().any(|name| name == product) { + return Err(CliFailure::domain(format!( + "component {product} is missing; run rstack install {product}" + ))); + } + let component = manifest.components.components.get(product).ok_or_else(|| { + CliFailure::operational(format!("selected release lacks component {product}")) + })?; + let executable = lease + .path() + .join("components") + .join(product) + .join(&component.entrypoint) + .join(&component.entrypoint); + let mut command = ProcessCommand::new(&executable); + set_selected_generation_environment(&mut command, lease.path(), &selection, &manifest)?; + let product_has_format = arguments + .iter() + .take_while(|argument| *argument != "--") + .any(|argument| { + argument == "--format" + || argument + .to_str() + .is_some_and(|value| value.starts_with("--format=")) + }); + if format_supplied && !product_has_format { + let format = cli + .format + .first() + .expect("explicit report format is parsed"); + let format = match format { + ReportFormat::Human => "human", + ReportFormat::Json => "json", + }; + let delimiter = arguments + .iter() + .position(|argument| argument == "--") + .unwrap_or(arguments.len()); + command.args(&arguments[..delimiter]); + command.args(["--format", format]); + command.args(&arguments[delimiter..]); + } else { + command.args(arguments); + } + let mut flags = rustix::io::fcntl_getfd(lease.lease_file()).map_err(|error| { + CliFailure::operational(format!("cannot inspect invocation lease: {error}")) + })?; + flags.remove(rustix::io::FdFlags::CLOEXEC); + rustix::io::fcntl_setfd(lease.lease_file(), flags).map_err(|error| { + CliFailure::operational(format!("cannot inherit invocation lease: {error}")) + })?; + Err(CliFailure::operational(format!( + "cannot launch {product}: {}", + command.exec() + ))) +} + +fn set_selected_generation_environment( + command: &mut ProcessCommand, + generation: &std::path::Path, + selection: &install::InstalledSelection, + manifest: &ReleaseManifest, +) -> Result<(), CliFailure> { + let generation = generation.canonicalize().map_err(|error| { + CliFailure::operational(format!("cannot resolve selected generation: {error}")) + })?; + let platform = install::platform().map_err(CliFailure::operational)?; + command.env("RSTACK_GENERATION_DIR", &generation); + command.env("RSTACK_RELEASE_TAG", &manifest.release.tag); + for (component_name, binary, variable) in [ + ("breg", "bregctl", "RSTACK_BREGCTL_BIN"), + ("breg", "breg", "RSTACK_BREG_BIN"), + ("breg", "breg-mcp", "RSTACK_BREG_MCP_BIN"), + ("breg", "breg-review", "RSTACK_BREG_REVIEW_BIN"), + ("casework", "caseworkctl", "RSTACK_CASEWORKCTL_BIN"), + ("casework", "casework", "RSTACK_CASEWORK_BIN"), + ("evidence", "evidencectl", "RSTACK_EVIDENCECTL_BIN"), + ("evidence", "evidence", "RSTACK_EVIDENCE_BIN"), + ( + "evidence", + "evidence-oid4vci", + "RSTACK_EVIDENCE_OID4VCI_BIN", + ), + ("discovery", "discoveryctl", "RSTACK_DISCOVERYCTL_BIN"), + ("discovery", "discovery", "RSTACK_DISCOVERY_BIN"), + ("messaging", "messagingctl", "RSTACK_MESSAGINGCTL_BIN"), + ("messaging", "messaging", "RSTACK_MESSAGING_BIN"), + ("scheduling", "schedulingctl", "RSTACK_SCHEDULINGCTL_BIN"), + ("scheduling", "scheduling", "RSTACK_SCHEDULING_BIN"), + ("coordinator", "coordinatorctl", "RSTACK_COORDINATORCTL_BIN"), + ("coordinator", "coordinator", "RSTACK_COORDINATOR_BIN"), + ] { + if !selection + .components + .iter() + .any(|name| name == component_name) + { + command.env_remove(variable); + continue; + } + let Some(component) = manifest.components.components.get(component_name) else { + continue; + }; + let Some(payload) = component.platforms.get(platform) else { + continue; + }; + let suffix = if platform == "macos-arm64" { + ".tar.gz" + } else { + "" + }; + let asset = format!("{binary}-{}-{platform}{suffix}", manifest.release.tag); + if payload.assets.iter().any(|item| item.name == asset) { + command.env( + variable, + generation + .join("components") + .join(component_name) + .join(binary) + .join(binary), + ); + } else { + command.env_remove(variable); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selected_generation_projects_optional_service_binaries() { + let generation = tempfile::tempdir().unwrap(); + let platform = install::platform().unwrap(); + let suffix = if platform == "macos-arm64" { + ".tar.gz" + } else { + "" + }; + let manifest: ReleaseManifest = serde_json::from_value(json!({ + "schema_version": "registry-stack.release-candidate.v2", + "repository": "registrystack/registry-stack", + "release": {"tag":"v0.44.0","version":"0.44.0","source_sha":"a".repeat(40),"release_id":"test"}, + "payloads": [], + "components": { + "schema_version": "registry-stack.components.v1", + "release": "v0.44.0", + "launcher": {}, + "components": { + "breg": {"entrypoint":"bregctl","dependencies":[],"platforms":{ + (platform): {"assets":[ + {"name":format!("breg-mcp-v0.44.0-{platform}{suffix}"),"size":1,"sha256":"a".repeat(64)}, + {"name":format!("breg-review-v0.44.0-{platform}{suffix}"),"size":1,"sha256":"a".repeat(64)} + ],"capabilities":{}} + }}, + "evidence": {"entrypoint":"evidencectl","dependencies":[],"platforms":{ + (platform): {"assets":[ + {"name":format!("evidence-oid4vci-v0.44.0-{platform}{suffix}"),"size":1,"sha256":"a".repeat(64)} + ],"capabilities":{}} + }} + } + } + })).unwrap(); + let selection = install::InstalledSelection { + schema_version: "registry.rstack.selection.v1".to_owned(), + release: "v0.44.0".to_owned(), + platform: platform.to_owned(), + components: vec!["breg".to_owned(), "evidence".to_owned()], + }; + let mut command = ProcessCommand::new("true"); + assert!(set_selected_generation_environment( + &mut command, + generation.path(), + &selection, + &manifest + ) + .is_ok()); + let projected: std::collections::BTreeMap<_, _> = command + .get_envs() + .filter_map(|(key, value)| value.map(|value| (key.to_owned(), value.to_owned()))) + .collect(); + let canonical_generation = generation.path().canonicalize().unwrap(); + for (variable, component, binary) in [ + ("RSTACK_BREG_MCP_BIN", "breg", "breg-mcp"), + ("RSTACK_BREG_REVIEW_BIN", "breg", "breg-review"), + ( + "RSTACK_EVIDENCE_OID4VCI_BIN", + "evidence", + "evidence-oid4vci", + ), + ] { + assert_eq!( + projected.get(std::ffi::OsStr::new(variable)), + Some( + &canonical_generation + .join("components") + .join(component) + .join(binary) + .join(binary) + .into_os_string() + ) + ); + } + } +} diff --git a/crates/registry-rstack/src/install.rs b/crates/registry-rstack/src/install.rs new file mode 100644 index 0000000000..81a39eac4e --- /dev/null +++ b/crates/registry-rstack/src/install.rs @@ -0,0 +1,1099 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Release-pinned bootstrap and component payload staging. + +use crate::assets::{checksum_for, verify_bytes, AssetProvider}; +use crate::store::{GenerationStore, PublicLinkStatus}; +use crate::{Asset, ReleaseManifest, Selection}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::io::Read; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_METADATA_BYTES: u64 = 16 * 1024 * 1024; + +pub struct BootstrapOptions { + pub release_manifest: PathBuf, + pub checksum_file: PathBuf, + pub launcher_asset: PathBuf, + pub asset_dir: Option, + pub install_root: PathBuf, + pub bin_dir: PathBuf, + pub components: Vec, + pub all: bool, +} + +#[derive(Clone, Debug)] +pub struct ManagerOptions { + pub install_root: PathBuf, + pub bin_dir: PathBuf, + pub asset_dir: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct InstallReport { + pub schema_version: &'static str, + pub release: String, + pub generation: String, + pub components: Vec, + pub download_bytes: u64, + pub public_link_created: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UninstallReport { + pub schema_version: &'static str, + pub release: String, + pub generation: String, + pub components: Vec, + pub removed: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UninstallAllReport { + pub schema_version: &'static str, + pub removed_generations: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct UpdateReport { + pub schema_version: &'static str, + pub current_release: String, + pub target_release: String, + pub generation: Option, + pub components: Vec, + pub added_components: Vec, + pub download_bytes: u64, + pub preview: bool, + pub activated: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ManagerErrorClass { + Domain, + Operational, +} + +#[derive(Debug)] +pub struct ManagerError { + pub class: ManagerErrorClass, + pub message: String, +} + +impl ManagerError { + fn domain(message: impl Into) -> Self { + Self { + class: ManagerErrorClass::Domain, + message: message.into(), + } + } + + fn operational(message: impl Into) -> Self { + Self { + class: ManagerErrorClass::Operational, + message: message.into(), + } + } +} + +impl fmt::Display for ManagerError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ManagerError {} + +impl From for ManagerError { + fn from(message: String) -> Self { + Self::operational(message) + } +} + +impl From<&str> for ManagerError { + fn from(message: &str) -> Self { + Self::operational(message) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InstalledSelection { + pub schema_version: String, + pub release: String, + pub platform: String, + pub components: Vec, +} + +pub fn platform() -> Result<&'static str, String> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Ok("linux-amd64"), + ("linux", "aarch64") => Ok("linux-arm64"), + ("macos", "aarch64") => Ok("macos-arm64"), + _ => Err("no Registry Stack launcher is published for this platform".into()), + } +} + +pub fn bootstrap(options: BootstrapOptions) -> Result { + if options.all && !options.components.is_empty() { + return Err("select component names or --all, but not both".into()); + } + let manifest_bytes = read_small_file(&options.release_manifest)?; + let checksum_bytes = read_small_file(&options.checksum_file)?; + let manifest_name = options + .release_manifest + .file_name() + .and_then(|name| name.to_str()) + .ok_or("release manifest path has no UTF-8 basename")?; + let digest = checksum_for(&checksum_bytes, manifest_name)?; + verify_bytes(manifest_name, &manifest_bytes, &digest)?; + let manifest = ReleaseManifest::parse(&manifest_bytes)?; + if manifest_name + != format!( + "registry-stack-{}-release-manifest.json", + manifest.release.tag + ) + { + return Err("release manifest filename differs from its exact release".into()); + } + if manifest.release.version != env!("CARGO_PKG_VERSION") { + return Err("bootstrap launcher version differs from release manifest".into()); + } + let target = platform()?; + let launcher = manifest + .components + .launcher + .get(target) + .ok_or_else(|| format!("launcher is unavailable on {target}"))?; + if options + .launcher_asset + .file_name() + .and_then(|name| name.to_str()) + != Some(launcher.name.as_str()) + { + return Err("launcher asset path differs from release manifest".into()); + } + let launcher_digest = checksum_for(&checksum_bytes, &launcher.name)?; + if launcher_digest != launcher.sha256 { + return Err("launcher checksum disagrees with release manifest".into()); + } + let plan = if options.all { + manifest.components.select(target, Selection::All)? + } else if options.components.is_empty() { + crate::SelectionPlan { + components: Vec::new(), + download_bytes: launcher.size, + } + } else { + manifest + .components + .select(target, Selection::Names(options.components.clone()))? + }; + + let store = GenerationStore::new(options.install_root, options.bin_dir); + let mutation = store.lock_mutation()?; + let name = unique_generation_name(&manifest.release.tag)?; + let staged = mutation.stage_generation(&name)?; + let raw_dir = staged.path().join(".assets"); + fs::create_dir(&raw_dir).map_err(|error| format!("cannot stage release assets: {error}"))?; + let local_launcher = AssetProvider::new( + &manifest.release.tag, + Some( + options + .launcher_asset + .parent() + .ok_or("launcher asset has no parent directory")? + .to_path_buf(), + ), + )?; + stage_asset( + &local_launcher, + launcher, + &raw_dir, + staged.path(), + "rstack", + &manifest.release.version, + )?; + + let source = AssetProvider::new(&manifest.release.tag, options.asset_dir)?; + for component_name in &plan.components { + let component = &manifest.components.components[component_name]; + let payload = &component.platforms[target]; + for asset in &payload.assets { + verify_asset_checksum(&checksum_bytes, asset)?; + let binary = binary_name(&asset.name, &manifest.release.tag, target)?; + let destination = staged + .path() + .join("components") + .join(component_name) + .join(&binary); + fs::create_dir_all(&destination) + .map_err(|error| format!("cannot stage {component_name}: {error}"))?; + stage_asset( + &source, + asset, + &raw_dir, + &destination, + &binary, + &manifest.release.version, + )?; + } + } + fs::remove_dir(&raw_dir).map_err(|error| format!("cannot clear staging area: {error}"))?; + fs::write(staged.path().join("manifest.json"), &manifest_bytes) + .map_err(|error| format!("cannot stage release manifest: {error}"))?; + fs::write(staged.path().join("SHA256SUMS"), &checksum_bytes) + .map_err(|error| format!("cannot stage release checksums: {error}"))?; + let selection = InstalledSelection { + schema_version: "registry-stack.install-selection.v1".into(), + release: manifest.release.tag.clone(), + platform: target.into(), + components: plan.components.clone(), + }; + fs::write( + staged.path().join("selection.json"), + serde_json::to_vec_pretty(&selection) + .map_err(|error| format!("cannot encode installation selection: {error}"))?, + ) + .map_err(|error| format!("cannot stage installation selection: {error}"))?; + let generation = staged.publish_preverified()?; + let link = mutation.ensure_public_link()?; + mutation.activate(&generation)?; + Ok(InstallReport { + schema_version: "registry.rstack.install.v1", + release: manifest.release.tag, + generation: generation.name().to_owned(), + components: plan.components, + download_bytes: plan.download_bytes, + public_link_created: link == PublicLinkStatus::Created, + }) +} + +/// Add components to the selected release and atomically activate the complete result. +pub fn install( + options: ManagerOptions, + components: Vec, + all: bool, +) -> Result { + if (all && !components.is_empty()) || (!all && components.is_empty()) { + return Err(ManagerError::domain( + "select component names or --all, but not both", + )); + } + let store = GenerationStore::new(options.install_root, options.bin_dir); + let mutation = store.lock_mutation()?; + let current = mutation + .current()? + .ok_or("no installation is selected; run a release-pinned rstack installer")?; + let active = read_active_release(current.path())?; + let requested = if all { + active + .manifest + .components + .select(&active.selection.platform, Selection::All) + .map_err(manager_selection_error)? + } else { + active + .manifest + .components + .select(&active.selection.platform, Selection::Names(components)) + .map_err(manager_selection_error)? + }; + let desired_names: BTreeSet<_> = active + .selection + .components + .iter() + .cloned() + .chain(requested.components) + .collect(); + let desired = resolve_selection( + &active.manifest, + &active.selection.platform, + desired_names.into_iter().collect(), + )?; + if desired == active.selection.components { + return Ok(InstallReport { + schema_version: "registry.rstack.install.v1", + release: active.selection.release, + generation: current.name().to_owned(), + components: desired, + download_bytes: 0, + public_link_created: false, + }); + } + + let installed: BTreeSet<_> = active.selection.components.iter().cloned().collect(); + let added: Vec<_> = desired + .iter() + .filter(|name| !installed.contains(*name)) + .cloned() + .collect(); + let download_bytes = + component_download_bytes(&active.manifest, &active.selection.platform, &added)?; + let provider = AssetProvider::new(&active.selection.release, options.asset_dir)?; + let generation_name = unique_generation_name(&active.selection.release)?; + let staged = mutation.stage_generation(&generation_name)?; + copy_generation_base(current.path(), staged.path())?; + copy_components(current.path(), staged.path(), &active.selection.components)?; + let raw_dir = staged.path().join(".assets"); + fs::create_dir(&raw_dir).map_err(|error| format!("cannot stage release assets: {error}"))?; + for component_name in &added { + let component = &active.manifest.components.components[component_name]; + let payload = &component.platforms[&active.selection.platform]; + for asset in &payload.assets { + verify_asset_checksum(&active.checksums, asset)?; + let binary = binary_name( + &asset.name, + &active.selection.release, + &active.selection.platform, + )?; + let destination = staged + .path() + .join("components") + .join(component_name) + .join(&binary); + fs::create_dir_all(&destination) + .map_err(|error| format!("cannot stage {component_name}: {error}"))?; + stage_asset( + &provider, + asset, + &raw_dir, + &destination, + &binary, + &active.manifest.release.version, + )?; + } + } + fs::remove_dir(&raw_dir).map_err(|error| format!("cannot clear staging area: {error}"))?; + write_selection(staged.path(), &active.selection, desired.clone())?; + let generation = staged.publish_preverified()?; + let link = mutation.ensure_public_link()?; + mutation.activate(&generation)?; + Ok(InstallReport { + schema_version: "registry.rstack.install.v1", + release: active.selection.release, + generation: generation.name().to_owned(), + components: desired, + download_bytes, + public_link_created: link == PublicLinkStatus::Created, + }) +} + +/// Preview or activate an exact newer release while preserving the installed product set. +pub fn update( + options: &ManagerOptions, + release: &str, + preview: bool, +) -> Result { + parse_release_version(release).map_err(ManagerError::domain)?; + let store = GenerationStore::new(&options.install_root, &options.bin_dir); + if preview { + let current = store.acquire_current().map_err(manager_current_error)?; + let active = read_active_release(current.path())?; + let plan = prepare_update(&active, release, options.asset_dir.clone())?; + return Ok(UpdateReport { + schema_version: "registry.rstack.update.v1", + current_release: active.selection.release, + target_release: release.to_owned(), + generation: None, + components: plan.components, + added_components: plan.added_components, + download_bytes: plan.download_bytes, + preview: true, + activated: false, + }); + } + + let mutation = store.lock_mutation()?; + let current = mutation.current()?.ok_or_else(|| { + ManagerError::domain("no installation is selected; run a release-pinned rstack installer") + })?; + let active = read_active_release(current.path())?; + let plan = prepare_update(&active, release, options.asset_dir.clone())?; + if release == active.selection.release { + return Ok(UpdateReport { + schema_version: "registry.rstack.update.v1", + current_release: active.selection.release.clone(), + target_release: release.to_owned(), + generation: Some(current.name().to_owned()), + components: plan.components, + added_components: Vec::new(), + download_bytes: 0, + preview: false, + activated: false, + }); + } + + let provider = AssetProvider::new(release, options.asset_dir.clone())?; + let generation_name = unique_generation_name(release)?; + let staged = mutation.stage_generation(&generation_name)?; + let raw_dir = staged.path().join(".assets"); + fs::create_dir(&raw_dir).map_err(|error| format!("cannot stage release assets: {error}"))?; + let launcher = &plan.target.manifest.components.launcher[&active.selection.platform]; + verify_asset_checksum(&plan.target.checksums, launcher)?; + stage_asset( + &provider, + launcher, + &raw_dir, + staged.path(), + "rstack", + &plan.target.manifest.release.version, + )?; + for component_name in &plan.components { + let component = &plan.target.manifest.components.components[component_name]; + let payload = &component.platforms[&active.selection.platform]; + for asset in &payload.assets { + verify_asset_checksum(&plan.target.checksums, asset)?; + let binary = binary_name(&asset.name, release, &active.selection.platform)?; + let destination = staged + .path() + .join("components") + .join(component_name) + .join(&binary); + fs::create_dir_all(&destination) + .map_err(|error| format!("cannot stage {component_name}: {error}"))?; + stage_asset( + &provider, + asset, + &raw_dir, + &destination, + &binary, + &plan.target.manifest.release.version, + )?; + } + } + fs::remove_dir(&raw_dir).map_err(|error| format!("cannot clear staging area: {error}"))?; + fs::write( + staged.path().join("manifest.json"), + &plan.target.manifest_bytes, + ) + .map_err(|error| format!("cannot stage release manifest: {error}"))?; + fs::write(staged.path().join("SHA256SUMS"), &plan.target.checksums) + .map_err(|error| format!("cannot stage release checksums: {error}"))?; + write_selection( + staged.path(), + &InstalledSelection { + schema_version: "registry-stack.install-selection.v1".into(), + release: release.to_owned(), + platform: active.selection.platform.clone(), + components: Vec::new(), + }, + plan.components.clone(), + )?; + let generation = staged.publish_preverified()?; + mutation.ensure_public_link()?; + mutation.activate(&generation)?; + Ok(UpdateReport { + schema_version: "registry.rstack.update.v1", + current_release: active.selection.release, + target_release: release.to_owned(), + generation: Some(generation.name().to_owned()), + components: plan.components, + added_components: plan.added_components, + download_bytes: plan.download_bytes, + preview: false, + activated: true, + }) +} + +/// Remove selected components while preserving the launcher and all retained payloads. +pub fn uninstall( + options: ManagerOptions, + components: Vec, +) -> Result { + if components.is_empty() { + return Err(ManagerError::domain( + "select one or more installed components", + )); + } + let store = GenerationStore::new(options.install_root, options.bin_dir); + let mutation = store.lock_mutation()?; + let current = mutation + .current()? + .ok_or("no installation is selected; run a release-pinned rstack installer")?; + let active = read_active_release(current.path())?; + let installed: BTreeSet<_> = active.selection.components.iter().cloned().collect(); + let removed: BTreeSet<_> = components.into_iter().collect(); + for name in &removed { + if !active.manifest.components.components.contains_key(name) { + return Err(ManagerError::domain(format!("unknown component {name}"))); + } + if !installed.contains(name) { + return Err(ManagerError::domain(format!( + "component {name} is not installed" + ))); + } + } + let retained: Vec<_> = active + .selection + .components + .iter() + .filter(|name| !removed.contains(*name)) + .cloned() + .collect(); + for component_name in &retained { + let component = &active.manifest.components.components[component_name]; + if let Some(dependency) = component + .dependencies + .iter() + .find(|dependency| removed.contains(*dependency)) + { + return Err(ManagerError::domain(format!( + "cannot uninstall {dependency}: it is required by {component_name}" + ))); + } + } + + let generation_name = unique_generation_name(&active.selection.release)?; + let staged = mutation.stage_generation(&generation_name)?; + copy_generation_base(current.path(), staged.path())?; + copy_components(current.path(), staged.path(), &retained)?; + write_selection(staged.path(), &active.selection, retained.clone())?; + let generation = staged.publish_preverified()?; + mutation.ensure_public_link()?; + mutation.activate(&generation)?; + Ok(UninstallReport { + schema_version: "registry.rstack.uninstall.v1", + release: active.selection.release, + generation: generation.name().to_owned(), + components: retained, + removed: removed.into_iter().collect(), + }) +} + +/// Remove the complete managed installation after proving every generation is idle and owned. +pub fn uninstall_all(options: ManagerOptions) -> Result { + let store = GenerationStore::new(options.install_root, options.bin_dir); + let mutation = store.lock_mutation()?; + let report = mutation.remove_installation().map_err(|message| { + if message.contains(" is in use;") { + ManagerError::domain(message) + } else { + ManagerError::operational(message) + } + })?; + Ok(UninstallAllReport { + schema_version: "registry.rstack.uninstall-all.v1", + removed_generations: report.removed_generations, + }) +} + +fn manager_selection_error(message: String) -> ManagerError { + if message.starts_with("unknown component ") || message.contains(" is unavailable on ") { + ManagerError::domain(message) + } else { + ManagerError::operational(message) + } +} + +fn manager_current_error(message: String) -> ManagerError { + if message == "no Registry Stack installation is selected" { + ManagerError::domain("no installation is selected; run a release-pinned rstack installer") + } else { + ManagerError::operational(message) + } +} + +pub fn read_selection(generation: &Path) -> Result { + let bytes = read_small_file(&generation.join("selection.json"))?; + let selection: InstalledSelection = serde_json::from_slice(&bytes) + .map_err(|error| format!("cannot parse installation selection: {error}"))?; + if selection.schema_version != "registry-stack.install-selection.v1" { + return Err("unsupported installation selection schema".into()); + } + Ok(selection) +} + +struct ActiveRelease { + manifest: ReleaseManifest, + manifest_bytes: Vec, + checksums: Vec, + selection: InstalledSelection, +} + +struct ResolvedRelease { + manifest: ReleaseManifest, + manifest_bytes: Vec, + checksums: Vec, +} + +struct UpdatePlan { + target: ResolvedRelease, + components: Vec, + added_components: Vec, + download_bytes: u64, +} + +fn read_active_release(generation: &Path) -> Result { + let manifest_bytes = read_small_file(&generation.join("manifest.json"))?; + let checksums = read_small_file(&generation.join("SHA256SUMS"))?; + let manifest = ReleaseManifest::parse(&manifest_bytes)?; + let manifest_name = format!( + "registry-stack-{}-release-manifest.json", + manifest.release.tag + ); + let digest = checksum_for(&checksums, &manifest_name)?; + verify_bytes(&manifest_name, &manifest_bytes, &digest)?; + let selection = read_selection(generation)?; + if selection.release != manifest.release.tag { + return Err("installed selection release differs from its manifest".into()); + } + if selection.platform != platform()? { + return Err(format!( + "installed selection targets {}, not this platform", + selection.platform + )); + } + let resolved = resolve_selection(&manifest, &selection.platform, selection.components.clone())?; + if resolved != selection.components { + return Err("installed component selection is incomplete or noncanonical".into()); + } + Ok(ActiveRelease { + manifest, + manifest_bytes, + checksums, + selection, + }) +} + +fn prepare_update( + active: &ActiveRelease, + release: &str, + asset_dir: Option, +) -> Result { + let current_version = parse_release_version(&active.selection.release)?; + let target_version = parse_release_version(release).map_err(ManagerError::domain)?; + if target_version < current_version { + return Err(ManagerError::domain(format!( + "refusing downgrade from {} to {release}", + active.selection.release + ))); + } + let target = resolve_release(release, asset_dir)?; + if release == active.selection.release && target.manifest_bytes != active.manifest_bytes { + return Err(ManagerError::operational( + "selected release metadata differs from the immutable installed release", + )); + } + let selection = if active.selection.components.is_empty() { + let launcher = target + .manifest + .components + .launcher + .get(&active.selection.platform) + .ok_or_else(|| { + ManagerError::domain(format!( + "launcher is unavailable on {}", + active.selection.platform + )) + })?; + crate::SelectionPlan { + components: Vec::new(), + download_bytes: launcher.size, + } + } else { + target + .manifest + .components + .select( + &active.selection.platform, + Selection::Names(active.selection.components.clone()), + ) + .map_err(manager_selection_error)? + }; + let installed: BTreeSet<_> = active.selection.components.iter().cloned().collect(); + let added_components = selection + .components + .iter() + .filter(|component| !installed.contains(*component)) + .cloned() + .collect(); + Ok(UpdatePlan { + target, + components: selection.components, + added_components, + download_bytes: selection.download_bytes, + }) +} + +fn resolve_release( + release: &str, + asset_dir: Option, +) -> Result { + let provider = AssetProvider::new(release, asset_dir)?; + let checksums = provider.fetch_metadata("SHA256SUMS")?; + let manifest_name = format!("registry-stack-{release}-release-manifest.json"); + let manifest_bytes = provider.fetch_metadata(&manifest_name)?; + let digest = checksum_for(&checksums, &manifest_name)?; + verify_bytes(&manifest_name, &manifest_bytes, &digest)?; + let manifest = ReleaseManifest::parse(&manifest_bytes)?; + if manifest.release.tag != release { + return Err(ManagerError::operational( + "target release manifest differs from the exact requested release", + )); + } + Ok(ResolvedRelease { + manifest, + manifest_bytes, + checksums, + }) +} + +fn parse_release_version(release: &str) -> Result<(u64, u64, u64), String> { + let version = release + .strip_prefix('v') + .ok_or("release must start with v")?; + let mut parts = version.split('.'); + let major = parse_version_part(parts.next(), release)?; + let minor = parse_version_part(parts.next(), release)?; + let patch = parse_version_part(parts.next(), release)?; + if parts.next().is_some() { + return Err(format!( + "release {release} must be an exact canonical vX.Y.Z tag" + )); + } + Ok((major, minor, patch)) +} + +fn parse_version_part(part: Option<&str>, release: &str) -> Result { + let part = + part.ok_or_else(|| format!("release {release} must be an exact canonical vX.Y.Z tag"))?; + if part.is_empty() + || (part.len() > 1 && part.starts_with('0')) + || !part.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(format!( + "release {release} must be an exact canonical vX.Y.Z tag" + )); + } + part.parse() + .map_err(|_| format!("release {release} must be an exact canonical vX.Y.Z tag")) +} + +fn resolve_selection( + manifest: &ReleaseManifest, + platform: &str, + names: Vec, +) -> Result, String> { + if names.is_empty() { + return Ok(Vec::new()); + } + manifest + .components + .select(platform, Selection::Names(names)) + .map(|plan| plan.components) +} + +fn component_download_bytes( + manifest: &ReleaseManifest, + platform: &str, + components: &[String], +) -> Result { + let mut bytes = 0_u64; + for component_name in components { + for asset in &manifest.components.components[component_name].platforms[platform].assets { + bytes = bytes + .checked_add(asset.size) + .ok_or("download size overflows u64")?; + } + } + Ok(bytes) +} + +fn verify_asset_checksum(checksums: &[u8], asset: &Asset) -> Result<(), String> { + if checksum_for(checksums, &asset.name)? != asset.sha256 { + return Err(format!( + "asset {} checksum disagrees with release manifest", + asset.name + )); + } + Ok(()) +} + +fn copy_generation_base(source: &Path, destination: &Path) -> Result<(), String> { + for entry in fs::read_dir(source) + .map_err(|error| format!("cannot inspect active generation: {error}"))? + { + let entry = entry.map_err(|error| format!("cannot inspect active generation: {error}"))?; + let name = entry.file_name(); + if matches!( + name.to_str(), + Some(".rstack-ownership.json" | ".rstack-lease" | "selection.json" | "components") + ) { + continue; + } + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|error| format!("cannot inspect active generation entry: {error}"))?; + if !metadata.file_type().is_file() { + return Err(format!( + "active generation entry {} is not a regular file", + entry.path().display() + )); + } + copy_regular_file(&entry.path(), &destination.join(&name))?; + } + fs::create_dir(destination.join("components")) + .map_err(|error| format!("cannot stage component directory: {error}")) +} + +fn copy_components(source: &Path, destination: &Path, components: &[String]) -> Result<(), String> { + for component in components { + copy_regular_tree( + &source.join("components").join(component), + &destination.join("components").join(component), + )?; + } + Ok(()) +} + +fn copy_regular_tree(source: &Path, destination: &Path) -> Result<(), String> { + let metadata = fs::symlink_metadata(source).map_err(|error| { + format!( + "cannot inspect retained component {}: {error}", + source.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "retained component {} is not a directory", + source.display() + )); + } + fs::create_dir(destination) + .map_err(|error| format!("cannot stage retained component: {error}"))?; + for entry in fs::read_dir(source) + .map_err(|error| format!("cannot inspect retained component: {error}"))? + { + let entry = entry.map_err(|error| format!("cannot inspect retained component: {error}"))?; + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|error| format!("cannot inspect retained component entry: {error}"))?; + let target = destination.join(entry.file_name()); + if metadata.is_dir() && !metadata.file_type().is_symlink() { + copy_regular_tree(&entry.path(), &target)?; + } else if metadata.file_type().is_file() { + copy_regular_file(&entry.path(), &target)?; + } else { + return Err(format!( + "retained component entry {} is not a regular file or directory", + entry.path().display() + )); + } + } + Ok(()) +} + +fn copy_regular_file(source: &Path, destination: &Path) -> Result<(), String> { + fs::copy(source, destination) + .map_err(|error| format!("cannot copy retained file {}: {error}", source.display()))?; + let permissions = fs::metadata(source) + .map_err(|error| format!("cannot inspect retained file {}: {error}", source.display()))? + .permissions(); + fs::set_permissions(destination, permissions) + .map_err(|error| format!("cannot preserve retained file permissions: {error}")) +} + +fn write_selection( + generation: &Path, + previous: &InstalledSelection, + components: Vec, +) -> Result<(), String> { + let selection = InstalledSelection { + schema_version: "registry-stack.install-selection.v1".into(), + release: previous.release.clone(), + platform: previous.platform.clone(), + components, + }; + fs::write( + generation.join("selection.json"), + serde_json::to_vec_pretty(&selection) + .map_err(|error| format!("cannot encode installation selection: {error}"))?, + ) + .map_err(|error| format!("cannot stage installation selection: {error}")) +} + +fn read_small_file(path: &Path) -> Result, String> { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("cannot inspect {}: {error}", path.display()))?; + if !metadata.file_type().is_file() || metadata.len() > MAX_METADATA_BYTES { + return Err(format!( + "{} must be a regular file of at most 16 MiB", + path.display() + )); + } + let mut file = + fs::File::open(path).map_err(|error| format!("cannot open {}: {error}", path.display()))?; + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(MAX_METADATA_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|error| format!("cannot read {}: {error}", path.display()))?; + if bytes.len() as u64 > MAX_METADATA_BYTES { + return Err(format!("{} exceeds 16 MiB", path.display())); + } + Ok(bytes) +} + +fn unique_generation_name(release: &str) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("system clock is before Unix epoch: {error}"))?; + Ok(format!( + "{release}-{}-{}", + std::process::id(), + now.as_nanos() + )) +} + +fn binary_name(asset_name: &str, release: &str, platform: &str) -> Result { + let suffix = format!( + "-{release}-{platform}{}", + if platform == "macos-arm64" { + ".tar.gz" + } else { + "" + } + ); + let binary = asset_name + .strip_suffix(&suffix) + .ok_or_else(|| format!("asset {asset_name} has the wrong release/platform suffix"))?; + if binary.is_empty() + || !binary + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(format!("asset {asset_name} has an invalid binary name")); + } + Ok(binary.to_owned()) +} + +fn stage_asset( + provider: &AssetProvider, + asset: &Asset, + raw_dir: &Path, + destination: &Path, + binary: &str, + expected_version: &str, +) -> Result<(), String> { + let raw = raw_dir.join(&asset.name); + provider.fetch(asset, &raw)?; + if asset.name.ends_with(".tar.gz") { + let archived_binary = asset + .name + .strip_suffix(".tar.gz") + .ok_or("archive asset has an invalid name")?; + extract_macos_archive(&raw, destination, archived_binary)?; + fs::rename(destination.join(archived_binary), destination.join(binary)) + .map_err(|error| format!("cannot stage executable {binary}: {error}"))?; + fs::remove_file(&raw) + .map_err(|error| format!("cannot clear staged archive {}: {error}", asset.name))?; + } else { + let executable = destination.join(binary); + fs::rename(&raw, &executable) + .map_err(|error| format!("cannot stage executable {binary}: {error}"))?; + fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)) + .map_err(|error| format!("cannot mark executable {binary}: {error}"))?; + } + smoke_binary(&destination.join(binary), binary, expected_version)?; + Ok(()) +} + +fn extract_macos_archive(archive: &Path, destination: &Path, binary: &str) -> Result<(), String> { + let names = Command::new("tar") + .arg("-tzf") + .arg(archive) + .output() + .map_err(|error| format!("cannot inspect {binary} archive: {error}"))?; + if !names.status.success() { + return Err(format!("cannot list {binary} archive")); + } + let listing = Command::new("tar") + .arg("-tvzf") + .arg(archive) + .output() + .map_err(|error| format!("cannot inspect {binary} archive: {error}"))?; + if !listing.status.success() { + return Err(format!("cannot inspect {binary} archive entries")); + } + let names = std::str::from_utf8(&names.stdout) + .map_err(|_| format!("{binary} archive has non-UTF-8 entry names"))?; + let listing = std::str::from_utf8(&listing.stdout) + .map_err(|_| format!("{binary} archive has a non-UTF-8 listing"))?; + let mut seen = BTreeSet::new(); + let mut has_notices = false; + let mut has_fips = false; + for name in names.lines() { + if name == "THIRD_PARTY_NOTICES" { + has_notices = true; + } else if name.starts_with("libaws_lc_fips_") && name.ends_with(".dylib") { + has_fips = true; + } else if name != binary { + return Err(format!("{binary} archive has an unsupported entry")); + } + if !seen.insert(name.to_owned()) { + return Err(format!("{binary} archive has a duplicate entry")); + } + } + if !seen.contains(binary) || !has_notices || !has_fips { + return Err(format!("{binary} archive is incomplete")); + } + if listing.lines().count() != seen.len() || listing.lines().any(|line| !line.starts_with('-')) { + return Err(format!("{binary} archive contains a nonregular entry")); + } + let result = Command::new("tar") + .env("COPYFILE_DISABLE", "1") + .arg("-xzf") + .arg(archive) + .arg("-C") + .arg(destination) + .output() + .map_err(|error| format!("cannot extract {binary} archive: {error}"))?; + if !result.status.success() { + return Err(format!("cannot extract {binary} archive")); + } + let extracted = fs::read_dir(destination) + .map_err(|error| format!("cannot inspect extracted {binary}: {error}"))? + .collect::, _>>() + .map_err(|error| format!("cannot inspect extracted {binary}: {error}"))?; + let extracted: Vec<_> = extracted + .into_iter() + .filter(|entry| entry.file_name() != ".assets") + .collect(); + if extracted.len() != seen.len() { + return Err(format!( + "{binary} archive extraction differs from inspected roster: expected {}, found {:?}", + seen.len(), + extracted + .iter() + .map(|entry| entry.file_name()) + .collect::>() + )); + } + for entry in extracted { + let name = entry.file_name().to_string_lossy().into_owned(); + if !seen.contains(&name) + || !fs::symlink_metadata(entry.path()) + .map_err(|error| format!("cannot inspect extracted {name}: {error}"))? + .file_type() + .is_file() + { + return Err(format!("{binary} archive extracted an unsupported entry")); + } + } + Ok(()) +} + +fn smoke_binary(path: &Path, binary: &str, expected_version: &str) -> Result<(), String> { + let output = Command::new(path) + .arg("--version") + .output() + .map_err(|error| format!("cannot start staged {binary}: {error}"))?; + let expected = format!("{binary} {expected_version}\n"); + if !output.status.success() || output.stdout != expected.as_bytes() { + return Err(format!("staged {binary} reports an unexpected version")); + } + Ok(()) +} diff --git a/crates/registry-rstack/src/lib.rs b/crates/registry-rstack/src/lib.rs new file mode 100644 index 0000000000..c40540bfc8 --- /dev/null +++ b/crates/registry-rstack/src/lib.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Public Registry Stack launcher and component manager. + +pub mod assets; +mod cli; +pub mod install; +mod model; +pub mod store; + +pub use cli::command; +pub use model::{Asset, ComponentManifest, ReleaseManifest, Selection, SelectionPlan}; +pub use store::{ + CleanupReport, Generation, GenerationStore, InvocationLease, MutationGuard, PreserveReason, + PreservedGeneration, PublicLinkStatus, StagedGeneration, +}; + +pub fn run() -> i32 { + cli::run() +} diff --git a/crates/registry-rstack/src/main.rs b/crates/registry-rstack/src/main.rs new file mode 100644 index 0000000000..c8ead17328 --- /dev/null +++ b/crates/registry-rstack/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + std::process::exit(registry_rstack::run()); +} diff --git a/crates/registry-rstack/src/model.rs b/crates/registry-rstack/src/model.rs new file mode 100644 index 0000000000..da17910b01 --- /dev/null +++ b/crates/registry-rstack/src/model.rs @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +const SCHEMA: &str = "registry-stack.components.v1"; +const PLATFORMS: [&str; 3] = ["linux-amd64", "linux-arm64", "macos-arm64"]; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ComponentManifest { + pub schema_version: String, + pub release: String, + pub launcher: BTreeMap, + pub components: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Component { + pub entrypoint: String, + pub dependencies: Vec, + pub platforms: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PlatformPayload { + pub assets: Vec, + pub capabilities: BTreeMap, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Asset { + pub name: String, + pub size: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ReleaseManifest { + pub schema_version: String, + pub repository: String, + pub release: ReleaseIdentity, + pub payloads: Vec, + pub components: ComponentManifest, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReleaseIdentity { + pub tag: String, + pub version: String, + pub source_sha: String, + pub release_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PublishedPayload { + pub name: String, + pub kind: String, + pub size: u64, + pub sha256: String, +} + +#[derive(Debug, Clone)] +pub enum Selection { + All, + Names(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectionPlan { + pub components: Vec, + pub download_bytes: u64, +} + +impl ComponentManifest { + pub fn validate(&self) -> Result<(), String> { + if self.schema_version != SCHEMA { + return Err(format!( + "unsupported component schema {}", + self.schema_version + )); + } + let version = self + .release + .strip_prefix('v') + .ok_or("release must use a v-prefixed tag")?; + if version.split('.').count() != 3 + || version.split('.').any(|part| { + part.is_empty() + || (part.len() > 1 && part.starts_with('0')) + || part.parse::().is_err() + }) + { + return Err("release must use a canonical semantic version tag".into()); + } + for (platform, asset) in &self.launcher { + if !PLATFORMS.contains(&platform.as_str()) { + return Err(format!("unsupported launcher platform {platform}")); + } + asset.validate()?; + if asset.name != asset_name("rstack", &self.release, platform) { + return Err(format!( + "launcher asset does not match release and platform: {}", + asset.name + )); + } + } + for (name, component) in &self.components { + if !valid_component_name(name) { + return Err(format!("invalid component name {name}")); + } + if !valid_component_name(&component.entrypoint) { + return Err(format!("invalid entrypoint for {name}")); + } + if component.platforms.is_empty() { + return Err(format!("component {name} has no published platform")); + } + for dependency in &component.dependencies { + if !self.components.contains_key(dependency) || dependency == name { + return Err(format!( + "component {name} has invalid dependency {dependency}" + )); + } + } + for (platform, payload) in &component.platforms { + if !PLATFORMS.contains(&platform.as_str()) { + return Err(format!("unsupported platform {platform} for {name}")); + } + let mut names = BTreeSet::new(); + for asset in &payload.assets { + asset.validate()?; + if !names.insert(&asset.name) { + return Err(format!("duplicate asset {} for {name}", asset.name)); + } + } + let entrypoint = asset_name(&component.entrypoint, &self.release, platform); + if !names.contains(&entrypoint) { + return Err(format!("component {name} lacks entrypoint {entrypoint}")); + } + } + } + Ok(()) + } + + pub fn select(&self, platform: &str, selection: Selection) -> Result { + self.validate()?; + let launcher = self + .launcher + .get(platform) + .ok_or_else(|| format!("launcher unavailable on {platform}"))?; + let requested: Vec = match selection { + Selection::All => self + .components + .iter() + .filter(|(_, component)| component.platforms.contains_key(platform)) + .map(|(name, _)| name.clone()) + .collect(), + Selection::Names(names) if names.is_empty() => { + return Err("select component names or --all".into()); + } + Selection::Names(names) => names, + }; + let mut selected = BTreeSet::new(); + let mut visiting = BTreeSet::new(); + for name in requested { + self.visit_component(&name, platform, &mut selected, &mut visiting)?; + } + let mut asset_names = BTreeSet::new(); + let mut bytes = launcher.size; + asset_names.insert(&launcher.name); + for name in &selected { + let payload = &self.components[name].platforms[platform]; + for asset in &payload.assets { + if asset_names.insert(&asset.name) { + bytes = bytes + .checked_add(asset.size) + .ok_or("download size overflows u64")?; + } + } + } + Ok(SelectionPlan { + components: selected.into_iter().collect(), + download_bytes: bytes, + }) + } + + fn visit_component( + &self, + name: &str, + platform: &str, + selected: &mut BTreeSet, + visiting: &mut BTreeSet, + ) -> Result<(), String> { + let component = self + .components + .get(name) + .ok_or_else(|| format!("unknown component {name}"))?; + if !component.platforms.contains_key(platform) { + return Err(format!("component {name} is unavailable on {platform}")); + } + if selected.contains(name) { + return Ok(()); + } + if !visiting.insert(name.to_owned()) { + return Err(format!("component dependency cycle at {name}")); + } + for dependency in &component.dependencies { + self.visit_component(dependency, platform, selected, visiting)?; + } + visiting.remove(name); + selected.insert(name.to_owned()); + Ok(()) + } +} + +impl ReleaseManifest { + pub fn parse(raw: &[u8]) -> Result { + let value = registry_platform_canonical_json::parse_json_strict(raw) + .map_err(|error| format!("cannot parse release manifest: {error}"))?; + let manifest: Self = serde_json::from_value(value) + .map_err(|error| format!("cannot parse release manifest: {error}"))?; + if manifest.schema_version != "registry-stack.release-candidate.v2" { + return Err("unsupported release-manifest schema".into()); + } + if manifest.repository != "registrystack/registry-stack" { + return Err("release manifest names another repository".into()); + } + if manifest.release.tag != format!("v{}", manifest.release.version) + || manifest.release.tag != manifest.components.release + { + return Err("component release identity differs from public release".into()); + } + if manifest.release.source_sha.len() != 40 + || !manifest + .release + .source_sha + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + || manifest.release.release_id.is_empty() + { + return Err("release identity is invalid".into()); + } + manifest.components.validate()?; + let mut published = BTreeMap::new(); + for payload in &manifest.payloads { + if published.insert(&payload.name, payload).is_some() { + return Err(format!("duplicate published payload {}", payload.name)); + } + } + let mut covered = BTreeSet::new(); + for asset in manifest.components.launcher.values().chain( + manifest + .components + .components + .values() + .flat_map(|component| { + component + .platforms + .values() + .flat_map(|platform| &platform.assets) + }), + ) { + let payload = published + .get(&asset.name) + .ok_or_else(|| format!("component asset {} is absent from payloads", asset.name))?; + if payload.kind != "binary" + || payload.size != asset.size + || payload.sha256 != asset.sha256 + { + return Err(format!( + "component asset {} disagrees with published payload", + asset.name + )); + } + if !covered.insert(&asset.name) { + return Err(format!( + "binary payload {} belongs to multiple components", + asset.name + )); + } + } + for payload in &manifest.payloads { + if payload.kind == "binary" && !covered.contains(&payload.name) { + return Err(format!( + "binary payload {} is not classified by components", + payload.name + )); + } + } + Ok(manifest) + } +} + +impl Asset { + pub fn validate(&self) -> Result<(), String> { + if self.name.is_empty() + || self.name == "." + || self.name == ".." + || self.name.contains('/') + || self.name.contains('\\') + || self.name.contains('\0') + { + return Err("asset name must be a safe basename".into()); + } + if self.size == 0 { + return Err(format!("asset {} has zero size", self.name)); + } + if self.sha256.len() != 64 + || !self + .sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + return Err(format!("asset {} has an invalid SHA-256 digest", self.name)); + } + Ok(()) + } +} + +fn valid_component_name(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + && !name.starts_with('-') + && !name.ends_with('-') +} + +fn asset_name(binary: &str, release: &str, platform: &str) -> String { + let suffix = if platform == "macos-arm64" { + ".tar.gz" + } else { + "" + }; + format!("{binary}-{release}-{platform}{suffix}") +} diff --git a/crates/registry-rstack/src/store.rs b/crates/registry-rstack/src/store.rs new file mode 100644 index 0000000000..e9672ac3f9 --- /dev/null +++ b/crates/registry-rstack/src/store.rs @@ -0,0 +1,1324 @@ +// SPDX-License-Identifier: Apache-2.0 +//! User-local immutable installation generations, activation, and invocation leases. + +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{ErrorKind, Read, Write}; +#[cfg(unix)] +use std::os::fd::{AsRawFd, RawFd}; +#[cfg(unix)] +use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const OWNERSHIP_SCHEMA: &str = "registry-stack.generation-ownership.v1"; +const OWNERSHIP_FILE: &str = ".rstack-ownership.json"; +const LEASE_FILE: &str = ".rstack-lease"; +const MUTATION_LOCK: &str = ".mutation.lock"; +const REMOVAL_DIR: &str = ".removing"; +const MAX_OWNERSHIP_BYTES: u64 = 16 * 1024 * 1024; + +static UNIQUE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug)] +pub struct GenerationStore { + install_root: PathBuf, + bin_dir: PathBuf, +} + +#[derive(Debug)] +pub struct MutationGuard<'store> { + store: &'store GenerationStore, + _lock: File, +} + +#[derive(Debug)] +pub struct StagedGeneration<'mutation, 'store> { + mutation: &'mutation MutationGuard<'store>, + name: String, + path: PathBuf, + published: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Generation { + name: String, + path: PathBuf, +} + +#[derive(Debug)] +pub struct InvocationLease { + generation: Generation, + lease: File, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PublicLinkStatus { + Created, + AlreadyOwned, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PreserveReason { + Active, + InUse, + Modified, + Unowned, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreservedGeneration { + pub name: String, + pub reason: PreserveReason, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CleanupReport { + pub removed: Vec, + pub preserved: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FullRemovalReport { + pub removed_generations: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct OwnershipInventory { + schema_version: String, + generation: String, + files: BTreeMap, + directories: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct OwnedFile { + size: u64, + sha256: String, + mode: u32, +} + +struct RemovalGeneration { + name: String, + path: PathBuf, + inventory: Option, + lease: Option, + staged_for_removal: bool, +} + +impl GenerationStore { + pub fn new(install_root: impl Into, bin_dir: impl Into) -> Self { + Self { + install_root: install_root.into(), + bin_dir: bin_dir.into(), + } + } + + pub fn install_root(&self) -> &Path { + &self.install_root + } + + pub fn generations_dir(&self) -> PathBuf { + self.install_root.join("generations") + } + + pub fn current_link(&self) -> PathBuf { + self.install_root.join("current") + } + + pub fn public_link(&self) -> PathBuf { + self.bin_dir.join("rstack") + } + + pub(crate) fn public_link_target(&self) -> Result { + let target = self.current_link().join("rstack"); + if target.is_absolute() { + return Ok(target); + } + std::env::current_dir() + .map(|directory| directory.join(target)) + .map_err(|error| format!("cannot resolve public rstack link target: {error}")) + } + + /// Report whether a complete current installation is selected without + /// creating directories, lock files, or any other local state. + pub fn has_installation(&self) -> Result { + match fs::symlink_metadata(&self.install_root) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => { + return Err(format!( + "installation root {} must be a directory", + self.install_root.display() + )) + } + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(format!("cannot inspect installation root: {error}")), + } + match fs::symlink_metadata(self.current_link()) { + Ok(_) => { + self.inspect_existing_layout()?; + let selection_lock = open_existing_lock(&self.install_root.join(MUTATION_LOCK))?; + FileExt::lock_shared(&selection_lock) + .map_err(|error| format!("cannot lock Registry Stack installation: {error}"))?; + self.resolve_current().map(|_| true) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!("cannot inspect current generation: {error}")), + } + } + + /// Serialize an installation mutation. Installation state must be read only + /// after this guard has been acquired. + pub fn lock_mutation(&self) -> Result, String> { + self.ensure_layout()?; + let lock = open_lock(&self.install_root.join(MUTATION_LOCK))?; + FileExt::lock_exclusive(&lock) + .map_err(|error| format!("cannot lock Registry Stack installation: {error}"))?; + Ok(MutationGuard { + store: self, + _lock: lock, + }) + } + + /// Select the current generation and acquire its shared lease under one + /// shared mutation lock, so cleanup cannot pass between those operations. + pub fn acquire_current(&self) -> Result { + self.inspect_existing_layout()?; + let selection_lock = open_existing_lock(&self.install_root.join(MUTATION_LOCK))?; + FileExt::lock_shared(&selection_lock) + .map_err(|error| format!("cannot lock Registry Stack installation: {error}"))?; + let generation = self.resolve_current()?; + let lease_path = generation.path.join(LEASE_FILE); + let metadata = fs::symlink_metadata(&lease_path) + .map_err(|error| format!("cannot inspect generation lease: {error}"))?; + if !metadata.file_type().is_file() { + return Err("selected generation lease is not a regular file".into()); + } + let lease = OpenOptions::new() + .read(true) + .write(true) + .open(&lease_path) + .map_err(|error| format!("cannot open generation lease: {error}"))?; + FileExt::lock_shared(&lease) + .map_err(|error| format!("cannot acquire generation lease: {error}"))?; + drop(selection_lock); + Ok(InvocationLease { generation, lease }) + } + + fn ensure_layout(&self) -> Result<(), String> { + ensure_directory(&self.install_root, "installation root")?; + ensure_directory(&self.generations_dir(), "generation directory") + } + + fn inspect_existing_layout(&self) -> Result<(), String> { + let generations = self.generations_dir(); + for (path, description) in [ + (&self.install_root, "installation root"), + (&generations, "generation directory"), + ] { + let metadata = fs::symlink_metadata(path).map_err(|error| { + if error.kind() == ErrorKind::NotFound { + "no Registry Stack installation is selected".to_owned() + } else { + format!("cannot inspect {description}: {error}") + } + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "{description} {} must be a directory", + path.display() + )); + } + } + Ok(()) + } + + fn resolve_current(&self) -> Result { + let current = self.current_link(); + let metadata = fs::symlink_metadata(¤t).map_err(|error| { + if error.kind() == ErrorKind::NotFound { + "no Registry Stack installation is selected".to_owned() + } else { + format!("cannot inspect current generation: {error}") + } + })?; + if !metadata.file_type().is_symlink() { + return Err("current installation entry is unrelated; refusing to use it".into()); + } + let target = fs::read_link(¤t) + .map_err(|error| format!("cannot read current generation: {error}"))?; + let name = generation_name_from_target(&target)?; + let path = self.generations_dir().join(&name); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("current generation {name} is unavailable: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "current generation {name} is not an owned directory" + )); + } + let inventory = read_inventory(&path, &name)?; + let observed = inventory_generation(&path, &name)?; + if observed != inventory { + return Err(format!( + "selected generation {name} changed after installation" + )); + } + Ok(Generation { name, path }) + } +} + +impl MutationGuard<'_> { + /// Re-read the selected generation after acquiring the mutation lock. + pub fn current(&self) -> Result, String> { + match fs::symlink_metadata(self.store.current_link()) { + Ok(_) => self.store.resolve_current().map(Some), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("cannot inspect current generation: {error}")), + } + } + + pub fn stage_generation(&self, name: &str) -> Result, String> { + validate_generation_name(name)?; + let destination = self.store.generations_dir().join(name); + match fs::symlink_metadata(&destination) { + Ok(_) => { + return Err(format!( + "generation {name} already exists; refusing to replace it" + )); + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(format!("cannot inspect generation {name}: {error}")), + } + let staging_root = self.store.install_root.join(".staging"); + ensure_directory(&staging_root, "generation staging directory")?; + let path = staging_root.join(format!("{name}.{}", unique_suffix())); + fs::create_dir(&path) + .map_err(|error| format!("cannot create generation staging directory: {error}"))?; + Ok(StagedGeneration { + mutation: self, + name: name.to_owned(), + path, + published: false, + }) + } + + /// Atomically switch `current` to a complete generation published by this store. + pub fn activate(&self, generation: &Generation) -> Result<(), String> { + let expected = self.store.generations_dir().join(&generation.name); + if generation.path != expected { + return Err("generation belongs to another installation".into()); + } + let inventory = read_inventory(&generation.path, &generation.name)?; + let observed = inventory_generation(&generation.path, &generation.name)?; + if inventory != observed { + return Err(format!( + "generation {} changed after it was staged; refusing activation", + generation.name + )); + } + + let current = self.store.current_link(); + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if !metadata.file_type().is_symlink() { + return Err( + "current installation entry is unrelated; refusing to replace it".into(), + ); + } + let target = fs::read_link(¤t) + .map_err(|error| format!("cannot read current generation: {error}"))?; + let old_name = generation_name_from_target(&target)?; + read_inventory(&self.store.generations_dir().join(&old_name), &old_name).map_err( + |_| "current installation link is unrelated; refusing to replace it".to_owned(), + )?; + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(format!("cannot inspect current generation: {error}")), + } + + let temporary = self + .store + .install_root + .join(format!(".current.{}", unique_suffix())); + let target = PathBuf::from("generations").join(&generation.name); + create_symlink(&target, &temporary)?; + if let Err(error) = fs::rename(&temporary, ¤t) { + let cleanup = fs::remove_file(&temporary); + return match cleanup { + Ok(()) => Err(format!( + "cannot activate generation {}: {error}", + generation.name + )), + Err(cleanup) => Err(format!( + "cannot activate generation {}: {error}; cannot remove temporary link: {cleanup}", + generation.name + )), + }; + } + sync_directory(&self.store.install_root, "installation root") + } + + /// Create the single public executable link, while preserving every other + /// pre-existing file or link at that path. + pub fn ensure_public_link(&self) -> Result { + ensure_directory(&self.store.bin_dir, "public binary directory")?; + let link = self.store.public_link(); + let target = self.store.public_link_target()?; + match fs::symlink_metadata(&link) { + Ok(metadata) => { + if metadata.file_type().is_symlink() + && fs::read_link(&link) + .map_err(|error| format!("cannot read public rstack link: {error}"))? + == target + { + return Ok(PublicLinkStatus::AlreadyOwned); + } + return Err(format!( + "{} is unrelated; refusing to replace it", + link.display() + )); + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(format!("cannot inspect {}: {error}", link.display())), + } + create_symlink(&target, &link)?; + sync_directory(&self.store.bin_dir, "public binary directory")?; + Ok(PublicLinkStatus::Created) + } + + /// Remove only inactive generations whose complete recorded identity still + /// matches and whose kernel lease can be acquired exclusively. + pub fn cleanup(&self) -> Result { + let active = match fs::symlink_metadata(self.store.current_link()) { + Ok(_) => Some(self.store.resolve_current()?.name), + Err(error) if error.kind() == ErrorKind::NotFound => None, + Err(error) => return Err(format!("cannot inspect current generation: {error}")), + }; + let mut entries = fs::read_dir(self.store.generations_dir()) + .map_err(|error| format!("cannot list installation generations: {error}"))? + .collect::, _>>() + .map_err(|error| format!("cannot list installation generations: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + let mut report = CleanupReport::default(); + + for entry in entries { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("cannot inspect generation {name}: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + report.preserve(name, PreserveReason::Unowned); + continue; + } + if active.as_deref() == Some(name.as_str()) { + report.preserve(name, PreserveReason::Active); + continue; + } + let inventory = match read_inventory(&path, &name) { + Ok(inventory) => inventory, + Err(_) => { + report.preserve(name, PreserveReason::Unowned); + continue; + } + }; + let lease_path = path.join(LEASE_FILE); + match fs::symlink_metadata(&lease_path) { + Ok(metadata) if metadata.file_type().is_file() => {} + _ => { + report.preserve(name, PreserveReason::Modified); + continue; + } + } + let lease = OpenOptions::new() + .read(true) + .write(true) + .open(&lease_path) + .map_err(|error| format!("cannot open lease for generation {name}: {error}"))?; + match FileExt::try_lock_exclusive(&lease) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::WouldBlock => { + report.preserve(name, PreserveReason::InUse); + continue; + } + Err(error) => { + return Err(format!( + "cannot inspect lease for generation {name}: {error}" + )); + } + } + let observed = match inventory_generation(&path, &name) { + Ok(observed) => observed, + Err(_) => { + report.preserve(name, PreserveReason::Modified); + continue; + } + }; + if observed != inventory { + report.preserve(name, PreserveReason::Modified); + continue; + } + remove_owned_generation(&path, &inventory, lease)?; + report.removed.push(name); + } + if !report.removed.is_empty() { + sync_directory(&self.store.generations_dir(), "generation directory")?; + } + Ok(report) + } + + /// Remove the owned public entry and every owned generation after proving + /// that none are in use or changed. The mutation lock and its directory + /// remain as the serialization point for a later reinstall. + pub fn remove_installation(&self) -> Result { + self.preflight_removal_layout()?; + match fs::symlink_metadata(self.store.current_link()) { + Ok(_) => { + self.current()?; + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(format!("cannot inspect current generation: {error}")), + } + + let public_link = self.store.public_link(); + let remove_public_link = match fs::symlink_metadata(&public_link) { + Ok(metadata) => { + if !metadata.file_type().is_symlink() + || fs::read_link(&public_link) + .map_err(|error| format!("cannot read public rstack link: {error}"))? + != self.store.public_link_target()? + { + return Err(format!( + "{} is unrelated; refusing to remove it", + public_link.display() + )); + } + true + } + Err(error) if error.kind() == ErrorKind::NotFound => false, + Err(error) => { + return Err(format!( + "cannot inspect public rstack link {}: {error}", + public_link.display() + )); + } + }; + + let mut entries = fs::read_dir(self.store.generations_dir()) + .map_err(|error| format!("cannot list installation generations: {error}"))? + .collect::, _>>() + .map_err(|error| format!("cannot list installation generations: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + let mut owned = Vec::new(); + for entry in entries { + let name = entry + .file_name() + .to_str() + .ok_or("generation name is not UTF-8")? + .to_owned(); + validate_generation_name(&name)?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("cannot inspect generation {name}: {error}"))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "generation {name} is unowned; refusing full uninstall" + )); + } + let inventory = read_inventory(&path, &name).map_err(|error| { + format!("{error}; refusing full uninstall while unowned files remain") + })?; + let observed = inventory_generation(&path, &name)?; + if observed != inventory { + return Err(format!( + "generation {name} changed after installation; refusing full uninstall" + )); + } + let lease = lock_generation_lease(&path, &name, true)?; + owned.push(RemovalGeneration { + name, + path, + inventory: Some(inventory), + lease, + staged_for_removal: false, + }); + } + + let removal_root = self.store.install_root.join(REMOVAL_DIR); + if removal_root.exists() { + let mut entries = fs::read_dir(&removal_root) + .map_err(|error| format!("cannot inspect removal recovery: {error}"))? + .collect::, _>>() + .map_err(|error| format!("cannot inspect removal recovery: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let name = entry + .file_name() + .to_str() + .ok_or("removal generation name is not UTF-8")? + .to_owned(); + validate_generation_name(&name)?; + if owned.iter().any(|generation| generation.name == name) { + return Err(format!( + "generation {name} appears in active and removal storage" + )); + } + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(|error| { + format!("cannot inspect removal generation {name}: {error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "removal generation {name} is unowned; refusing full uninstall" + )); + } + let inventory = match read_inventory(&path, &name) { + Ok(inventory) => { + if !remaining_generation_matches(&path, &name, &inventory)? { + return Err(format!( + "removal generation {name} contains changed or unrelated files" + )); + } + Some(inventory) + } + Err(_) + if fs::read_dir(&path) + .map_err(|error| { + format!("cannot inspect removal generation {name}: {error}") + })? + .next() + .is_none() => + { + None + } + Err(error) => { + return Err(format!( + "{error}; refusing full uninstall while unowned files remain" + )); + } + }; + let lease = lock_generation_lease(&path, &name, false)?; + owned.push(RemovalGeneration { + name, + path, + inventory, + lease, + staged_for_removal: true, + }); + } + } + if owned.is_empty() { + return Err("no Registry Stack installation is selected".into()); + } + + if remove_public_link { + fs::remove_file(&public_link).map_err(|error| { + format!( + "cannot remove public link {}: {error}", + public_link.display() + ) + })?; + sync_directory(&self.store.bin_dir, "public binary directory")?; + } + match fs::symlink_metadata(self.store.current_link()) { + Ok(_) => { + fs::remove_file(self.store.current_link()) + .map_err(|error| format!("cannot remove current generation link: {error}"))?; + sync_directory(&self.store.install_root, "installation root")?; + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(format!("cannot inspect current generation: {error}")), + } + + ensure_directory(&removal_root, "removal recovery directory")?; + sync_directory(&self.store.install_root, "installation root")?; + for generation in &mut owned { + if !generation.staged_for_removal { + let destination = removal_root.join(&generation.name); + fs::rename(&generation.path, &destination).map_err(|error| { + format!( + "cannot stage generation {} for removal: {error}", + generation.name + ) + })?; + generation.path = destination; + generation.staged_for_removal = true; + } + } + sync_directory(&self.store.generations_dir(), "generation directory")?; + sync_directory(&removal_root, "removal recovery directory")?; + + let mut report = FullRemovalReport::default(); + for generation in owned { + if let Some(inventory) = generation.inventory { + remove_owned_generation_recoverable( + &generation.path, + &inventory, + generation.lease, + )?; + } else { + fs::remove_dir(&generation.path).map_err(|error| { + format!( + "cannot remove empty generation recovery {}: {error}", + generation.path.display() + ) + })?; + } + report.removed_generations.push(generation.name); + } + fs::remove_dir(&removal_root) + .map_err(|error| format!("cannot remove recovery directory: {error}"))?; + fs::remove_dir(self.store.generations_dir()) + .map_err(|error| format!("cannot remove generation directory: {error}"))?; + let staging = self.store.install_root.join(".staging"); + if staging.exists() { + fs::remove_dir(&staging) + .map_err(|error| format!("cannot remove empty staging directory: {error}"))?; + } + sync_directory(&self.store.install_root, "installation root")?; + Ok(report) + } + + fn preflight_removal_layout(&self) -> Result<(), String> { + for entry in fs::read_dir(&self.store.install_root) + .map_err(|error| format!("cannot inspect installation root: {error}"))? + { + let entry = + entry.map_err(|error| format!("cannot inspect installation root: {error}"))?; + let name = entry.file_name(); + match name.to_str() { + Some("current" | "generations" | MUTATION_LOCK) => {} + Some(REMOVAL_DIR) => { + let metadata = fs::symlink_metadata(entry.path()).map_err(|error| { + format!("cannot inspect removal recovery directory: {error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err( + "removal recovery entry is unrelated; refusing full uninstall".into(), + ); + } + } + Some(".staging") => { + let metadata = fs::symlink_metadata(entry.path()).map_err(|error| { + format!("cannot inspect generation staging directory: {error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err( + "generation staging entry is unrelated; refusing full uninstall".into(), + ); + } + if fs::read_dir(entry.path()) + .map_err(|error| { + format!("cannot inspect generation staging directory: {error}") + })? + .next() + .is_some() + { + return Err( + "incomplete generation staging remains; refusing full uninstall".into(), + ); + } + } + _ => { + return Err(format!( + "installation root contains unrelated entry {}; refusing full uninstall", + entry.path().display() + )); + } + } + } + Ok(()) + } +} + +impl StagedGeneration<'_, '_> { + pub fn path(&self) -> &Path { + &self.path + } + + /// Publish a generation only after the caller has verified every release + /// asset. This method independently verifies the complete local shape, + /// records its identity, syncs it, and exposes it under `generations/`. + pub fn publish_preverified(mut self) -> Result { + validate_required_generation_files(&self.path)?; + for reserved in [OWNERSHIP_FILE, LEASE_FILE] { + match fs::symlink_metadata(self.path.join(reserved)) { + Ok(_) => { + return Err(format!( + "staged generation contains reserved entry {reserved}" + )); + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(format!("cannot inspect staged generation: {error}")), + } + } + let inventory = inventory_generation(&self.path, &self.name)?; + let lease_path = self.path.join(LEASE_FILE); + let lease = OpenOptions::new() + .write(true) + .create_new(true) + .open(&lease_path) + .map_err(|error| format!("cannot create generation lease: {error}"))?; + lease + .sync_all() + .map_err(|error| format!("cannot sync generation lease: {error}"))?; + let ownership_path = self.path.join(OWNERSHIP_FILE); + let encoded = serde_json::to_vec_pretty(&inventory) + .map_err(|error| format!("cannot encode generation ownership: {error}"))?; + let mut ownership = OpenOptions::new() + .write(true) + .create_new(true) + .open(&ownership_path) + .map_err(|error| format!("cannot create generation ownership: {error}"))?; + ownership + .write_all(&encoded) + .and_then(|()| ownership.write_all(b"\n")) + .and_then(|()| ownership.sync_all()) + .map_err(|error| format!("cannot write generation ownership: {error}"))?; + sync_tree(&self.path)?; + + let destination = self.mutation.store.generations_dir().join(&self.name); + match fs::symlink_metadata(&destination) { + Ok(_) => { + return Err(format!( + "generation {} already exists; refusing to replace it", + self.name + )); + } + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(format!("cannot inspect generation {}: {error}", self.name)), + } + fs::rename(&self.path, &destination) + .map_err(|error| format!("cannot publish generation {}: {error}", self.name))?; + sync_directory( + &self.mutation.store.generations_dir(), + "generation directory", + )?; + self.published = true; + Ok(Generation { + name: self.name.clone(), + path: destination, + }) + } +} + +impl Drop for StagedGeneration<'_, '_> { + fn drop(&mut self) { + if !self.published { + if let Err(error) = remove_staging_tree(&self.path) { + eprintln!( + "rstack: {error}; incomplete staging remains at {}", + self.path.display() + ); + } + } + } +} + +impl Generation { + pub fn name(&self) -> &str { + &self.name + } + + pub fn path(&self) -> &Path { + &self.path + } +} + +impl InvocationLease { + pub fn generation(&self) -> &Generation { + &self.generation + } + + pub fn path(&self) -> &Path { + self.generation.path() + } + + pub fn lease_file(&self) -> &File { + &self.lease + } + + /// The descriptor that must be made inheritable immediately before exec. + /// Keeping it open in the executed product and its nested helpers retains + /// the kernel lease even after the launcher process is replaced. + #[cfg(unix)] + pub fn lease_fd(&self) -> RawFd { + self.lease.as_raw_fd() + } +} + +impl CleanupReport { + pub fn reason_for(&self, name: &str) -> Option { + self.preserved + .iter() + .find(|generation| generation.name == name) + .map(|generation| generation.reason) + } + + fn preserve(&mut self, name: String, reason: PreserveReason) { + self.preserved.push(PreservedGeneration { name, reason }); + } +} + +fn validate_generation_name(name: &str) -> Result<(), String> { + if name.is_empty() + || name.len() > 128 + || !name + .bytes() + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()) + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) + { + return Err("generation name must be a safe ASCII basename".into()); + } + Ok(()) +} + +fn generation_name_from_target(target: &Path) -> Result { + let mut components = target.components(); + if components.next() != Some(Component::Normal("generations".as_ref())) { + return Err("current installation link is unrelated; refusing to follow it".into()); + } + let name = match components.next() { + Some(Component::Normal(name)) => name + .to_str() + .ok_or("current generation name is not UTF-8")? + .to_owned(), + _ => return Err("current installation link is unrelated; refusing to follow it".into()), + }; + if components.next().is_some() { + return Err("current installation link is unrelated; refusing to follow it".into()); + } + validate_generation_name(&name)?; + Ok(name) +} + +fn validate_required_generation_files(path: &Path) -> Result<(), String> { + for name in ["rstack", "manifest.json"] { + let candidate = path.join(name); + let metadata = fs::symlink_metadata(&candidate) + .map_err(|error| format!("staged generation lacks regular {name}: {error}"))?; + if !metadata.file_type().is_file() { + return Err(format!("staged generation {name} must be a regular file")); + } + } + #[cfg(unix)] + if fs::symlink_metadata(path.join("rstack")) + .map_err(|error| format!("cannot inspect staged rstack: {error}"))? + .permissions() + .mode() + & 0o111 + == 0 + { + return Err("staged generation rstack is not executable".into()); + } + Ok(()) +} + +fn inventory_generation(path: &Path, name: &str) -> Result { + let mut files = BTreeMap::new(); + let mut directories = Vec::new(); + inventory_directory(path, path, &mut files, &mut directories)?; + directories.sort(); + Ok(OwnershipInventory { + schema_version: OWNERSHIP_SCHEMA.to_owned(), + generation: name.to_owned(), + files, + directories, + }) +} + +fn inventory_directory( + root: &Path, + directory: &Path, + files: &mut BTreeMap, + directories: &mut Vec, +) -> Result<(), String> { + let mut entries = fs::read_dir(directory) + .map_err(|error| format!("cannot inspect staged generation: {error}"))? + .collect::, _>>() + .map_err(|error| format!("cannot inspect staged generation: {error}"))?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let relative = path + .strip_prefix(root) + .map_err(|_| "generation entry escaped its root")?; + if relative.components().count() == 1 + && matches!(relative.to_str(), Some(OWNERSHIP_FILE) | Some(LEASE_FILE)) + { + continue; + } + let relative_text = relative_path_text(relative)?; + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("cannot inspect generation entry {relative_text}: {error}"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "generation entry {relative_text} must not be a symlink" + )); + } + if metadata.is_dir() { + directories.push(relative_text); + inventory_directory(root, &path, files, directories)?; + } else if metadata.file_type().is_file() { + let mut file = File::open(&path).map_err(|error| { + format!("cannot read generation entry {relative_text}: {error}") + })?; + let mut hash = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = file.read(&mut buffer).map_err(|error| { + format!("cannot read generation entry {relative_text}: {error}") + })?; + if count == 0 { + break; + } + hash.update(&buffer[..count]); + } + let owned = OwnedFile { + size: metadata.len(), + sha256: hex::encode(hash.finalize()), + mode: file_mode(&metadata), + }; + if files.insert(relative_text.clone(), owned).is_some() { + return Err(format!("generation repeats entry {relative_text}")); + } + } else { + return Err(format!( + "generation entry {relative_text} has an unsupported type" + )); + } + } + Ok(()) +} + +fn read_inventory(path: &Path, name: &str) -> Result { + let ownership_path = path.join(OWNERSHIP_FILE); + let metadata = fs::symlink_metadata(&ownership_path) + .map_err(|error| format!("generation {name} has no ownership record: {error}"))?; + if !metadata.file_type().is_file() || metadata.len() > MAX_OWNERSHIP_BYTES { + return Err(format!("generation {name} has an invalid ownership record")); + } + let raw = fs::read(&ownership_path) + .map_err(|error| format!("cannot read generation {name} ownership: {error}"))?; + let inventory: OwnershipInventory = serde_json::from_slice(&raw) + .map_err(|error| format!("cannot parse generation {name} ownership: {error}"))?; + if inventory.schema_version != OWNERSHIP_SCHEMA || inventory.generation != name { + return Err(format!("generation {name} ownership identity is invalid")); + } + for path in inventory.files.keys().chain(inventory.directories.iter()) { + validate_inventory_path(path)?; + } + Ok(inventory) +} + +fn validate_inventory_path(path: &str) -> Result<(), String> { + let candidate = Path::new(path); + if path.is_empty() + || candidate.is_absolute() + || candidate + .components() + .any(|component| !matches!(component, Component::Normal(_))) + || matches!(path, OWNERSHIP_FILE | LEASE_FILE) + { + return Err(format!( + "generation ownership contains an unsafe path: {path}" + )); + } + Ok(()) +} + +fn remove_owned_generation( + path: &Path, + inventory: &OwnershipInventory, + lease: File, +) -> Result<(), String> { + for relative in inventory.files.keys() { + fs::remove_file(path.join(relative)).map_err(|error| { + format!( + "cannot remove owned generation file {}: {error}", + path.join(relative).display() + ) + })?; + } + fs::remove_file(path.join(OWNERSHIP_FILE)) + .map_err(|error| format!("cannot remove generation ownership record: {error}"))?; + fs::remove_file(path.join(LEASE_FILE)) + .map_err(|error| format!("cannot remove generation lease: {error}"))?; + drop(lease); + let mut directories = inventory.directories.clone(); + directories + .sort_by_key(|directory| std::cmp::Reverse(Path::new(directory).components().count())); + for relative in directories { + fs::remove_dir(path.join(&relative)).map_err(|error| { + format!( + "cannot remove owned generation directory {}: {error}", + path.join(relative).display() + ) + })?; + } + fs::remove_dir(path) + .map_err(|error| format!("cannot remove owned generation {}: {error}", path.display())) +} + +fn lock_generation_lease(path: &Path, name: &str, required: bool) -> Result, String> { + let lease_path = path.join(LEASE_FILE); + match fs::symlink_metadata(&lease_path) { + Ok(metadata) if metadata.file_type().is_file() => {} + Ok(_) => { + return Err(format!( + "generation {name} lease changed; refusing full uninstall" + )); + } + Err(error) if error.kind() == ErrorKind::NotFound && !required => return Ok(None), + Err(error) if error.kind() == ErrorKind::NotFound => { + return Err(format!( + "generation {name} lease changed; refusing full uninstall" + )); + } + Err(error) => { + return Err(format!( + "cannot inspect lease for generation {name}: {error}" + )) + } + } + let lease = OpenOptions::new() + .read(true) + .write(true) + .open(&lease_path) + .map_err(|error| format!("cannot open lease for generation {name}: {error}"))?; + match FileExt::try_lock_exclusive(&lease) { + Ok(()) => Ok(Some(lease)), + Err(error) if error.kind() == ErrorKind::WouldBlock => Err(format!( + "generation {name} is in use; refusing full uninstall" + )), + Err(error) => Err(format!( + "cannot inspect lease for generation {name}: {error}" + )), + } +} + +fn remaining_generation_matches( + path: &Path, + name: &str, + inventory: &OwnershipInventory, +) -> Result { + let observed = inventory_generation(path, name)?; + if observed + .files + .iter() + .any(|(file, identity)| inventory.files.get(file) != Some(identity)) + { + return Ok(false); + } + Ok(observed + .directories + .iter() + .all(|directory| inventory.directories.contains(directory))) +} + +fn remove_owned_generation_recoverable( + path: &Path, + inventory: &OwnershipInventory, + lease: Option, +) -> Result<(), String> { + if !remaining_generation_matches(path, &inventory.generation, inventory)? { + return Err(format!( + "removal generation {} contains changed or unrelated files", + inventory.generation + )); + } + for relative in inventory.files.keys() { + match fs::remove_file(path.join(relative)) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "cannot remove owned generation file {}: {error}", + path.join(relative).display() + )); + } + } + } + let mut directories = inventory.directories.clone(); + directories + .sort_by_key(|directory| std::cmp::Reverse(Path::new(directory).components().count())); + for relative in directories { + match fs::remove_dir(path.join(&relative)) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "cannot remove owned generation directory {}: {error}", + path.join(relative).display() + )); + } + } + } + drop(lease); + for reserved in [LEASE_FILE, OWNERSHIP_FILE] { + match fs::remove_file(path.join(reserved)) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "cannot remove generation metadata {reserved}: {error}" + )); + } + } + } + fs::remove_dir(path) + .map_err(|error| format!("cannot remove owned generation {}: {error}", path.display())) +} + +fn remove_staging_tree(path: &Path) -> Result<(), String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(format!("cannot inspect staging directory: {error}")), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("generation staging path is not a directory".into()); + } + let entries = fs::read_dir(path) + .map_err(|error| format!("cannot inspect staging directory: {error}"))? + .collect::, _>>() + .map_err(|error| format!("cannot inspect staging directory: {error}"))?; + for entry in entries { + let child = entry.path(); + let metadata = fs::symlink_metadata(&child) + .map_err(|error| format!("cannot inspect staging entry: {error}"))?; + if metadata.is_dir() && !metadata.file_type().is_symlink() { + remove_staging_tree(&child)?; + } else { + fs::remove_file(&child) + .map_err(|error| format!("cannot remove staging entry: {error}"))?; + } + } + fs::remove_dir(path).map_err(|error| format!("cannot remove staging directory: {error}")) +} + +fn sync_tree(path: &Path) -> Result<(), String> { + let entries = fs::read_dir(path) + .map_err(|error| format!("cannot sync staged generation: {error}"))? + .collect::, _>>() + .map_err(|error| format!("cannot sync staged generation: {error}"))?; + for entry in entries { + let child = entry.path(); + let metadata = fs::symlink_metadata(&child) + .map_err(|error| format!("cannot sync staged generation: {error}"))?; + if metadata.is_dir() { + sync_tree(&child)?; + } else if metadata.file_type().is_file() { + File::open(&child) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("cannot sync {}: {error}", child.display()))?; + } + } + sync_directory(path, "staged generation directory") +} + +fn ensure_directory(path: &Path, description: &str) -> Result<(), String> { + fs::create_dir_all(path) + .map_err(|error| format!("cannot create {description} {}: {error}", path.display()))?; + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("cannot inspect {description} {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "{description} {} must be a directory", + path.display() + )); + } + Ok(()) +} + +fn open_lock(path: &Path) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) if !metadata.file_type().is_file() => { + return Err("installation mutation lock is not a regular file".into()); + } + Ok(_) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "cannot inspect installation mutation lock: {error}" + )); + } + } + OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|error| format!("cannot open installation mutation lock: {error}")) +} + +fn open_existing_lock(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|error| { + if error.kind() == ErrorKind::NotFound { + "no Registry Stack installation is selected".to_owned() + } else { + format!("cannot inspect installation mutation lock: {error}") + } + })?; + if !metadata.file_type().is_file() { + return Err("installation mutation lock is not a regular file".into()); + } + OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|error| format!("cannot open installation mutation lock: {error}")) +} + +fn relative_path_text(path: &Path) -> Result { + let text = path + .to_str() + .ok_or("generation entry path is not UTF-8")? + .to_owned(); + validate_inventory_path(&text)?; + Ok(text) +} + +fn unique_suffix() -> String { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let sequence = UNIQUE_SEQUENCE.fetch_add(1, Ordering::Relaxed); + format!("{}-{nanos}-{sequence}", std::process::id()) +} + +#[cfg(unix)] +fn file_mode(metadata: &fs::Metadata) -> u32 { + metadata.mode() & 0o7777 +} + +#[cfg(not(unix))] +fn file_mode(_metadata: &fs::Metadata) -> u32 { + 0 +} + +#[cfg(unix)] +fn create_symlink(target: &Path, link: &Path) -> Result<(), String> { + symlink(target, link).map_err(|error| format!("cannot create link {}: {error}", link.display())) +} + +#[cfg(not(unix))] +fn create_symlink(_target: &Path, _link: &Path) -> Result<(), String> { + Err("generation links are unsupported on this platform".into()) +} + +fn sync_directory(path: &Path, description: &str) -> Result<(), String> { + File::open(path) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("cannot sync {description} {}: {error}", path.display())) +} diff --git a/crates/registry-rstack/tests/assets.rs b/crates/registry-rstack/tests/assets.rs new file mode 100644 index 0000000000..79e797384b --- /dev/null +++ b/crates/registry-rstack/tests/assets.rs @@ -0,0 +1,75 @@ +use registry_rstack::assets::{checksum_for, AssetProvider}; +use registry_rstack::Asset; +use std::fs; + +fn asset(name: &str, bytes: &[u8]) -> Asset { + use sha2::{Digest, Sha256}; + Asset { + name: name.to_owned(), + size: bytes.len() as u64, + sha256: hex::encode(Sha256::digest(bytes)), + } +} + +#[test] +fn checksum_lookup_requires_one_exact_lowercase_entry() { + let expected = "a".repeat(64); + let checksums = format!("{expected} rstack-v0.44.0-linux-amd64\n"); + assert_eq!( + checksum_for(checksums.as_bytes(), "rstack-v0.44.0-linux-amd64").unwrap(), + expected + ); + assert!(checksum_for( + format!("{checksums}{checksums}").as_bytes(), + "rstack-v0.44.0-linux-amd64" + ) + .is_err()); + assert!(checksum_for( + format!("{} rstack-v0.44.0-linux-amd64\n", "A".repeat(64)).as_bytes(), + "rstack-v0.44.0-linux-amd64" + ) + .is_err()); + assert!(checksum_for(checksums.as_bytes(), "rstack-v0.44.0-linux-arm64").is_err()); +} + +#[test] +fn offline_assets_are_verified_before_they_are_staged() { + let directory = tempfile::tempdir().unwrap(); + let output = tempfile::tempdir().unwrap(); + let payload = b"one verified executable"; + let name = "rstack-v0.44.0-linux-amd64"; + fs::write(directory.path().join(name), payload).unwrap(); + let provider = AssetProvider::new("v0.44.0", Some(directory.path().to_path_buf())).unwrap(); + provider + .fetch(&asset(name, payload), &output.path().join(name)) + .unwrap(); + assert_eq!(fs::read(output.path().join(name)).unwrap(), payload); + + let wrong_size = Asset { + size: 1, + ..asset(name, payload) + }; + assert!(provider + .fetch(&wrong_size, &output.path().join("wrong-size")) + .is_err()); + assert!(!output.path().join("wrong-size").exists()); + + fs::write(directory.path().join(name), b"tampered executable!!!").unwrap(); + assert!(provider + .fetch(&asset(name, payload), &output.path().join("wrong-hash")) + .is_err()); + assert!(!output.path().join("wrong-hash").exists()); +} + +#[test] +fn offline_asset_symlinks_are_refused() { + let directory = tempfile::tempdir().unwrap(); + let output = tempfile::tempdir().unwrap(); + let name = "rstack-v0.44.0-linux-amd64"; + fs::write(directory.path().join("real"), b"binary").unwrap(); + std::os::unix::fs::symlink("real", directory.path().join(name)).unwrap(); + let provider = AssetProvider::new("v0.44.0", Some(directory.path().to_path_buf())).unwrap(); + assert!(provider + .fetch(&asset(name, b"binary"), &output.path().join(name)) + .is_err()); +} diff --git a/crates/registry-rstack/tests/bootstrap.rs b/crates/registry-rstack/tests/bootstrap.rs new file mode 100644 index 0000000000..2b5f4a6699 --- /dev/null +++ b/crates/registry-rstack/tests/bootstrap.rs @@ -0,0 +1,212 @@ +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn scratch() -> tempfile::TempDir { + if let Ok(target) = std::env::var("CARGO_TARGET_DIR") { + tempfile::tempdir_in(target).unwrap() + } else { + tempfile::tempdir().unwrap() + } +} + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn release_fixture(directory: &Path) -> (PathBuf, PathBuf, PathBuf) { + let platform = match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "linux-amd64", + ("linux", "aarch64") => "linux-arm64", + ("macos", "aarch64") => "macos-arm64", + other => panic!("unsupported test platform: {other:?}"), + }; + let archive = platform == "macos-arm64"; + let asset_name = format!( + "rstack-v0.44.0-{platform}{}", + if archive { ".tar.gz" } else { "" } + ); + let launcher = directory.join(&asset_name); + if archive { + let contents = directory.join("archive"); + fs::create_dir(&contents).unwrap(); + fs::copy( + env!("CARGO_BIN_EXE_rstack"), + contents.join("rstack-v0.44.0-macos-arm64"), + ) + .unwrap(); + fs::write(contents.join("THIRD_PARTY_NOTICES"), "test fixture\n").unwrap(); + fs::write(contents.join("libaws_lc_fips_test.dylib"), "test fixture\n").unwrap(); + let result = Command::new("tar") + .args(["-czf"]) + .arg(&launcher) + .arg("-C") + .arg(&contents) + .args([ + "rstack-v0.44.0-macos-arm64", + "THIRD_PARTY_NOTICES", + "libaws_lc_fips_test.dylib", + ]) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + } else { + fs::copy(env!("CARGO_BIN_EXE_rstack"), &launcher).unwrap(); + } + let binary = fs::read(&launcher).unwrap(); + let manifest_name = "registry-stack-v0.44.0-release-manifest.json"; + let manifest_path = directory.join(manifest_name); + let manifest = json!({ + "schema_version": "registry-stack.release-candidate.v2", + "repository": "registrystack/registry-stack", + "release": { + "tag": "v0.44.0", + "version": "0.44.0", + "source_sha": "a".repeat(40), + "release_id": "test" + }, + "payloads": [{ + "name": asset_name, + "kind": "binary", + "size": binary.len(), + "sha256": digest(&binary) + }], + "components": { + "schema_version": "registry-stack.components.v1", + "release": "v0.44.0", + "launcher": {(platform): { + "name": asset_name, + "size": binary.len(), + "sha256": digest(&binary) + }}, + "components": {} + } + }); + let manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + fs::write(&manifest_path, &manifest_bytes).unwrap(); + let checksums = directory.join("SHA256SUMS"); + fs::write( + &checksums, + format!( + "{} {asset_name}\n{} {manifest_name}\n", + digest(&binary), + digest(&manifest_bytes) + ), + ) + .unwrap(); + (manifest_path, checksums, launcher) +} + +#[test] +fn launcher_only_bootstrap_activates_one_owned_generation() { + let workspace = scratch(); + let assets = workspace.path().join("assets"); + fs::create_dir(&assets).unwrap(); + let (manifest, checksums, launcher) = release_fixture(&assets); + let install_root = workspace.path().join("lib"); + let bin_dir = workspace.path().join("bin"); + let result = Command::new(env!("CARGO_BIN_EXE_rstack")) + .arg("bootstrap") + .arg("--release-manifest") + .arg(&manifest) + .arg("--checksum-file") + .arg(&checksums) + .arg("--launcher-asset") + .arg(&launcher) + .arg("--asset-dir") + .arg(&assets) + .arg("--install-root") + .arg(&install_root) + .arg("--bin-dir") + .arg(&bin_dir) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + assert!(fs::symlink_metadata(bin_dir.join("rstack")) + .unwrap() + .file_type() + .is_symlink()); + let version = Command::new(bin_dir.join("rstack")) + .arg("--version") + .output() + .unwrap(); + assert_eq!( + String::from_utf8(version.stdout).unwrap(), + "rstack 0.44.0\n" + ); + assert!(install_root.join("current").exists()); +} + +#[test] +fn relative_installation_paths_create_a_working_public_launcher() { + let workspace = scratch(); + let assets = workspace.path().join("assets"); + fs::create_dir(&assets).unwrap(); + let (manifest, checksums, launcher) = release_fixture(&assets); + let working_directory = workspace.path().join("working"); + fs::create_dir(&working_directory).unwrap(); + let result = Command::new(env!("CARGO_BIN_EXE_rstack")) + .current_dir(&working_directory) + .arg("bootstrap") + .arg("--release-manifest") + .arg(&manifest) + .arg("--checksum-file") + .arg(&checksums) + .arg("--launcher-asset") + .arg(&launcher) + .arg("--asset-dir") + .arg(&assets) + .args(["--install-root", "state", "--bin-dir", "public-bin"]) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + + let public_launcher = working_directory.join("public-bin/rstack"); + assert!(fs::symlink_metadata(&public_launcher) + .unwrap() + .file_type() + .is_symlink()); + let invocation_directory = workspace.path().join("elsewhere"); + fs::create_dir(&invocation_directory).unwrap(); + let version = Command::new(public_launcher) + .current_dir(invocation_directory) + .arg("--version") + .output() + .unwrap(); + assert!(version.status.success(), "{version:?}"); + assert_eq!(version.stdout, b"rstack 0.44.0\n"); +} + +#[test] +fn corrupted_public_manifest_refuses_before_activation() { + let workspace = scratch(); + let assets = workspace.path().join("assets"); + fs::create_dir(&assets).unwrap(); + let (manifest, checksums, launcher) = release_fixture(&assets); + let mut bytes = fs::read(&manifest).unwrap(); + bytes.push(b' '); + fs::write(&manifest, bytes).unwrap(); + let install_root = workspace.path().join("lib"); + let bin_dir = workspace.path().join("bin"); + let result = Command::new(env!("CARGO_BIN_EXE_rstack")) + .arg("bootstrap") + .arg("--release-manifest") + .arg(&manifest) + .arg("--checksum-file") + .arg(&checksums) + .arg("--launcher-asset") + .arg(&launcher) + .arg("--asset-dir") + .arg(&assets) + .arg("--install-root") + .arg(&install_root) + .arg("--bin-dir") + .arg(&bin_dir) + .output() + .unwrap(); + assert_eq!(result.status.code(), Some(3), "{result:?}"); + assert!(!install_root.join("current").exists()); + assert!(!bin_dir.join("rstack").exists()); +} diff --git a/crates/registry-rstack/tests/cli.rs b/crates/registry-rstack/tests/cli.rs new file mode 100644 index 0000000000..c049881cfa --- /dev/null +++ b/crates/registry-rstack/tests/cli.rs @@ -0,0 +1,256 @@ +use std::os::fd::OwnedFd; +use std::os::unix::net::UnixStream; +use std::process::{Command, Stdio}; + +fn run(arguments: &[&str]) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_rstack")) + .args(arguments) + .output() + .expect("run rstack") +} + +#[test] +fn root_command_metadata_states_the_human_default() { + let command = registry_rstack::command(); + let format = command + .get_arguments() + .find(|argument| argument.get_id() == "format") + .expect("format option"); + assert_eq!(format.get_default_values(), ["human"]); +} + +#[test] +fn version_and_root_help_work_without_an_installation() { + let version = run(&["--version"]); + assert!(version.status.success()); + assert_eq!( + String::from_utf8(version.stdout).unwrap(), + "rstack 0.44.0\n" + ); + + let help = run(&[]); + assert!(help.status.success()); + let text = String::from_utf8(help.stdout).unwrap(); + assert!(text.contains("rstack ")); + assert!(text.contains("components list")); + assert!(text.contains("breg")); + assert!(text.contains("evidence")); +} + +#[test] +fn unknown_component_and_duplicate_format_are_usage_failures() { + let unknown = run(&["install", "bogus"]); + assert_eq!(unknown.status.code(), Some(2)); + assert!(String::from_utf8(unknown.stderr) + .unwrap() + .contains("unknown component")); + + let duplicated = run(&[ + "--format", + "human", + "components", + "list", + "--format", + "json", + ]); + assert_eq!(duplicated.status.code(), Some(2)); + assert!(duplicated.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&duplicated.stdout).unwrap(); + assert!(report["error"]["message"] + .as_str() + .unwrap() + .contains("--format")); +} + +#[test] +fn completions_refuses_an_explicit_report_format_without_emitting_a_script() { + let before = run(&["--format", "human", "completions", "zsh"]); + assert_eq!(before.status.code(), Some(2), "{before:?}"); + assert!(before.stdout.is_empty(), "{before:?}"); + assert!(!String::from_utf8(before.stderr) + .unwrap() + .contains("#compdef")); + + let after = run(&["completions", "zsh", "--format", "json"]); + assert_eq!(after.status.code(), Some(2), "{after:?}"); + assert!(after.stderr.is_empty(), "{after:?}"); + assert!(!String::from_utf8_lossy(&after.stdout).contains("#compdef")); + let report: serde_json::Value = serde_json::from_slice(&after.stdout).unwrap(); + assert_eq!(report["error"]["class"], "usage"); +} + +#[test] +fn product_json_help_uses_the_public_command_tree_offline() { + let output = run(&["help", "evidence", "--format", "json"]); + assert_eq!(output.status.code(), Some(0)); + assert!(output.stderr.is_empty()); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["schema_version"], "registry.rstack.help.v1"); + assert_eq!(value["command"]["invocation"], "rstack evidence"); + assert!(value["command"]["subcommands"] + .as_array() + .unwrap() + .iter() + .any(|action| action["name"] == "init")); + assert!(!String::from_utf8(output.stdout) + .unwrap() + .contains("evidencectl")); +} + +#[test] +fn manifest_help_rewrites_the_command_without_rewriting_product_prose() { + let output = run(&["help", "manifest", "--format", "json"]); + assert!(output.status.success(), "{output:?}"); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["command"]["invocation"], "rstack manifest"); + assert_eq!( + report["command"]["subcommands"][0]["about"], + "Validate one Registry Manifest metadata document" + ); +} + +#[test] +fn action_help_accepts_json_after_the_product_without_an_installation() { + let output = run(&["evidence", "check", "--help", "--format", "json"]); + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["schema_version"], "registry.rstack.help.v1"); + assert_eq!(report["command"]["invocation"], "rstack evidence check"); + assert_eq!(report["command"]["name"], "check"); +} + +#[test] +fn offline_help_refuses_unknown_surplus_actions_and_options() { + for arguments in [ + &["evidence", "unknown", "--help"][..], + &["evidence", "check", "extra", "--help"][..], + &["evidence", "check", "--unknown", "--help"][..], + &["evidence", "check", "--help", "--", "extra"][..], + ] { + let output = run(arguments); + assert_eq!(output.status.code(), Some(2), "{arguments:?}: {output:?}"); + assert!(output.stdout.is_empty(), "{arguments:?}: {output:?}"); + assert!(!output.stderr.is_empty(), "{arguments:?}: {output:?}"); + } + + for arguments in [ + &["evidence", "check", "extra", "--help", "--format", "json"][..], + &["evidence", "check", "--unknown", "--help", "--format=json"][..], + ] { + let json = run(arguments); + assert_eq!(json.status.code(), Some(2), "{arguments:?}: {json:?}"); + assert!(json.stderr.is_empty(), "{arguments:?}: {json:?}"); + let report: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap(); + assert_eq!(report["error"]["class"], "usage"); + } +} + +#[test] +fn json_refusal_is_one_stdout_object_with_matching_exit_class() { + let output = run(&["--format", "json", "install", "bogus"]); + assert_eq!(output.status.code(), Some(2), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["schema_version"], "registry.rstack.error.v1"); + assert_eq!(report["error"]["class"], "usage"); + assert!(report["error"]["message"] + .as_str() + .unwrap() + .contains("unknown component")); + assert!(output.stdout.ends_with(b"\n")); + + let duplicated = run(&["--format", "human", "--format=json", "components", "list"]); + assert_eq!(duplicated.status.code(), Some(2)); + assert!(duplicated.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&duplicated.stdout).unwrap(); + assert_eq!(report["error"]["class"], "usage"); +} + +#[test] +fn doctor_reports_absent_installation_without_creating_state() { + let scratch = tempfile::tempdir().unwrap(); + let root = scratch.path().join("never-installed"); + let bin = scratch.path().join("bin"); + let output = Command::new(env!("CARGO_BIN_EXE_rstack")) + .arg("--install-root") + .arg(&root) + .arg("--bin-dir") + .arg(&bin) + .args(["doctor", "--format", "json"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["schema_version"], "registry.rstack.doctor.v1"); + assert_eq!(report["status"], "refused"); + assert!(!report["problems"].as_array().unwrap().is_empty()); + assert!(!root.exists()); + assert!(!bin.exists()); +} + +#[test] +fn piped_output_keeps_the_human_default() { + let scratch = tempfile::tempdir().unwrap(); + let output = run(&[ + "--install-root", + scratch.path().to_str().unwrap(), + "components", + "list", + ]); + assert!(output.status.success(), "{output:?}"); + let output = String::from_utf8(output.stdout).unwrap(); + assert!(output.starts_with("No installation selected.")); + assert!(output.contains("breg: availability unknown, version unavailable, path none")); + assert!(output.contains("coordinator: availability unknown, version unavailable, path none")); +} + +#[test] +fn unselected_component_inventory_has_stable_detail_fields() { + let scratch = tempfile::tempdir().unwrap(); + let output = run(&[ + "--install-root", + scratch.path().join("absent").to_str().unwrap(), + "components", + "list", + "--format", + "json", + ]); + assert!(output.status.success(), "{output:?}"); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let components = report["components"].as_array().unwrap(); + assert_eq!(components.len(), 9); + assert!(components.iter().all(|component| { + component["version"].is_null() + && component["path"].is_null() + && component["available"].is_null() + })); +} + +#[test] +fn closed_report_output_is_an_operational_failure() { + let scratch = tempfile::tempdir().unwrap(); + for format in ["human", "json"] { + let (reader, writer) = UnixStream::pair().unwrap(); + drop(reader); + let writer: OwnedFd = writer.into(); + let output = Command::new(env!("CARGO_BIN_EXE_rstack")) + .stdout(Stdio::from(writer)) + .args([ + "--install-root", + scratch.path().to_str().unwrap(), + "--format", + format, + "components", + "list", + ]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(3), "{output:?}"); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8(output.stderr) + .unwrap() + .contains("cannot write")); + } +} diff --git a/crates/registry-rstack/tests/component_selection.rs b/crates/registry-rstack/tests/component_selection.rs new file mode 100644 index 0000000000..364c279b02 --- /dev/null +++ b/crates/registry-rstack/tests/component_selection.rs @@ -0,0 +1,77 @@ +use registry_rstack::{ComponentManifest, Selection}; + +fn sample() -> ComponentManifest { + serde_json::from_str( + r#"{ + "schema_version":"registry-stack.components.v1", + "release":"v0.44.0", + "launcher":{ + "linux-amd64":{"name":"rstack-v0.44.0-linux-amd64","size":8,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + }, + "components":{ + "breg":{"entrypoint":"bregctl","dependencies":[],"platforms":{ + "linux-amd64":{"assets":[{"name":"bregctl-v0.44.0-linux-amd64","size":11,"sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}],"capabilities":{"tooling":true,"runtime":false}} + }}, + "casework":{"entrypoint":"caseworkctl","dependencies":["breg"],"platforms":{ + "linux-amd64":{"assets":[{"name":"caseworkctl-v0.44.0-linux-amd64","size":12,"sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}],"capabilities":{"tooling":true,"runtime":false}} + }}, + "scheduling":{"entrypoint":"schedulingctl","dependencies":[],"platforms":{ + "linux-amd64":{"assets":[{"name":"schedulingctl-v0.44.0-linux-amd64","size":13,"sha256":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}],"capabilities":{"tooling":true,"runtime":false}} + }} + } + }"#, + ) + .unwrap() +} + +#[test] +fn selection_adds_required_dependencies_and_counts_exact_assets() { + let manifest = sample(); + let selected = manifest + .select("linux-amd64", Selection::Names(vec!["casework".into()])) + .unwrap(); + assert_eq!(selected.components, vec!["breg", "casework"]); + assert_eq!(selected.download_bytes, 8 + 11 + 12); +} + +#[test] +fn unavailable_mixed_selection_refuses_whole_plan() { + let manifest = sample(); + let error = manifest + .select( + "macos-arm64", + Selection::Names(vec!["casework".into(), "scheduling".into()]), + ) + .unwrap_err(); + assert!(error.to_string().contains("unavailable")); + let error = manifest + .select( + "linux-amd64", + Selection::Names(vec!["casework".into(), "unknown".into()]), + ) + .unwrap_err(); + assert!(error.to_string().contains("unknown component")); +} + +#[test] +fn all_selects_only_baselines_available_on_platform() { + let manifest = sample(); + let selected = manifest.select("linux-amd64", Selection::All).unwrap(); + assert_eq!(selected.components, vec!["breg", "casework", "scheduling"]); + assert_eq!(selected.download_bytes, 8 + 11 + 12 + 13); +} + +#[test] +fn unknown_fields_and_invalid_digest_are_refused() { + let value = serde_json::json!({ + "schema_version":"registry-stack.components.v1", + "release":"v0.44.0", + "launcher":{"linux-amd64":{"name":"rstack","size":8,"sha256":"bad"}}, + "components":{} + }); + let manifest: ComponentManifest = serde_json::from_value(value.clone()).unwrap(); + assert!(manifest.validate().is_err()); + let mut wrong = value; + wrong["unexpected"] = serde_json::json!(true); + assert!(serde_json::from_value::(wrong).is_err()); +} diff --git a/crates/registry-rstack/tests/dispatch.rs b/crates/registry-rstack/tests/dispatch.rs new file mode 100644 index 0000000000..a7e62cc65a --- /dev/null +++ b/crates/registry-rstack/tests/dispatch.rs @@ -0,0 +1,303 @@ +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::{BufRead, BufReader}; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn platform() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => "linux-amd64", + ("linux", "aarch64") => "linux-arm64", + ("macos", "aarch64") => "macos-arm64", + other => panic!("unsupported test platform: {other:?}"), + } +} + +fn fixture(directory: &Path) -> (PathBuf, PathBuf, PathBuf) { + let platform = platform(); + let archived = platform == "macos-arm64"; + let suffix = if archived { ".tar.gz" } else { "" }; + let launcher_name = format!("rstack-v0.44.0-{platform}{suffix}"); + let evidence_name = format!("evidencectl-v0.44.0-{platform}{suffix}"); + let launcher = directory.join(&launcher_name); + let evidence = directory.join(&evidence_name); + let script = b"#!/usr/bin/env bash\nset -euo pipefail\nif [[ \"${1:-}\" == '--version' ]]; then printf 'evidencectl 0.44.0\\n'; exit 0; fi\nif [[ \"${1:-}\" == 'hold' ]]; then printf 'ready\\n'; sleep 5; exit 0; fi\nprintf 'generation:%s\\n' \"${RSTACK_GENERATION_DIR:-}\"\nprintf 'release:%s\\n' \"${RSTACK_RELEASE_TAG:-}\"\nprintf 'selected-evidencectl:%s\\n' \"${RSTACK_EVIDENCECTL_BIN:-}\"\nprintf 'selected-bregctl:%s\\n' \"${RSTACK_BREGCTL_BIN:-}\"\nprintf 'cwd:%s\\n' \"$PWD\"\nprintf 'arg:%s\\n' \"$@\"\nexit 7\n"; + if archived { + for (asset, binary, executable) in [ + (&launcher, "rstack-v0.44.0-macos-arm64", None), + ( + &evidence, + "evidencectl-v0.44.0-macos-arm64", + Some(script.as_slice()), + ), + ] { + let contents = directory.join(binary); + fs::create_dir(&contents).unwrap(); + let target = contents.join(binary); + if let Some(bytes) = executable { + fs::write(&target, bytes).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + } else { + fs::copy(env!("CARGO_BIN_EXE_rstack"), &target).unwrap(); + } + fs::write(contents.join("THIRD_PARTY_NOTICES"), "fixture\n").unwrap(); + fs::write(contents.join("libaws_lc_fips_test.dylib"), "fixture\n").unwrap(); + let output = Command::new("tar") + .arg("-czf") + .arg(asset) + .arg("-C") + .arg(&contents) + .args([binary, "THIRD_PARTY_NOTICES", "libaws_lc_fips_test.dylib"]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + } + } else { + fs::copy(env!("CARGO_BIN_EXE_rstack"), &launcher).unwrap(); + fs::write(&evidence, script).unwrap(); + fs::set_permissions(&evidence, fs::Permissions::from_mode(0o755)).unwrap(); + } + let launcher_bytes = fs::read(&launcher).unwrap(); + let evidence_bytes = fs::read(&evidence).unwrap(); + let manifest_name = "registry-stack-v0.44.0-release-manifest.json"; + let manifest_path = directory.join(manifest_name); + let manifest = json!({ + "schema_version": "registry-stack.release-candidate.v2", + "repository": "registrystack/registry-stack", + "release": {"tag":"v0.44.0","version":"0.44.0","source_sha":"a".repeat(40),"release_id":"test"}, + "payloads": [ + {"name":launcher_name,"kind":"binary","size":launcher_bytes.len(),"sha256":digest(&launcher_bytes)}, + {"name":evidence_name,"kind":"binary","size":evidence_bytes.len(),"sha256":digest(&evidence_bytes)} + ], + "components": { + "schema_version":"registry-stack.components.v1", + "release":"v0.44.0", + "launcher":{(platform):{"name":launcher_name,"size":launcher_bytes.len(),"sha256":digest(&launcher_bytes)}}, + "components":{"evidence":{ + "entrypoint":"evidencectl","dependencies":[], + "platforms":{(platform):{ + "assets":[{"name":evidence_name,"size":evidence_bytes.len(),"sha256":digest(&evidence_bytes)}], + "capabilities":{"tooling":true} + }} + }} + } + }); + let manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + fs::write(&manifest_path, &manifest_bytes).unwrap(); + let checksums = directory.join("SHA256SUMS"); + fs::write( + &checksums, + format!( + "{} {launcher_name}\n{} {evidence_name}\n{} {manifest_name}\n", + digest(&launcher_bytes), + digest(&evidence_bytes), + digest(&manifest_bytes) + ), + ) + .unwrap(); + (manifest_path, checksums, launcher) +} + +#[test] +fn old_invocation_keeps_its_generation_while_new_one_activates() { + let scratch = tempfile::tempdir().unwrap(); + let assets = scratch.path().join("assets"); + fs::create_dir(&assets).unwrap(); + let (manifest, checksums, launcher) = fixture(&assets); + let root = scratch.path().join("lib"); + let bin = scratch.path().join("bin"); + let bootstrap = || { + Command::new(env!("CARGO_BIN_EXE_rstack")) + .args(["bootstrap", "--release-manifest"]) + .arg(&manifest) + .arg("--checksum-file") + .arg(&checksums) + .arg("--launcher-asset") + .arg(&launcher) + .arg("--asset-dir") + .arg(&assets) + .arg("--install-root") + .arg(&root) + .arg("--bin-dir") + .arg(&bin) + .arg("evidence") + .output() + .unwrap() + }; + let first = bootstrap(); + assert!(first.status.success(), "{first:?}"); + let old_target = fs::read_link(root.join("current")).unwrap(); + let old_name = old_target.file_name().unwrap().to_str().unwrap().to_owned(); + let mut child = Command::new(bin.join("rstack")) + .arg("--install-root") + .arg(&root) + .arg("--bin-dir") + .arg(&bin) + .args(["evidence", "hold"]) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + let mut ready = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + assert_eq!(ready, "ready\n"); + let second = bootstrap(); + assert!(second.status.success(), "{second:?}"); + assert_ne!(fs::read_link(root.join("current")).unwrap(), old_target); + let store = registry_rstack::store::GenerationStore::new(&root, &bin); + let mutation = store.lock_mutation().unwrap(); + let report = mutation.cleanup().unwrap(); + assert_eq!( + report.reason_for(&old_name), + Some(registry_rstack::store::PreserveReason::InUse) + ); + drop(mutation); + assert!(child.wait().unwrap().success()); + let report = store.lock_mutation().unwrap().cleanup().unwrap(); + assert!(report.removed.contains(&old_name)); +} + +#[test] +fn selected_product_preserves_arguments_directory_and_exit_status() { + let scratch = if let Ok(target) = std::env::var("CARGO_TARGET_DIR") { + tempfile::tempdir_in(target).unwrap() + } else { + tempfile::tempdir().unwrap() + }; + let assets = scratch.path().join("assets"); + fs::create_dir(&assets).unwrap(); + let (manifest, checksums, launcher) = fixture(&assets); + let root = scratch.path().join("lib"); + let bin = scratch.path().join("bin"); + let installed = Command::new(env!("CARGO_BIN_EXE_rstack")) + .args(["bootstrap", "--release-manifest"]) + .arg(manifest) + .arg("--checksum-file") + .arg(checksums) + .arg("--launcher-asset") + .arg(launcher) + .arg("--asset-dir") + .arg(&assets) + .arg("--install-root") + .arg(&root) + .arg("--bin-dir") + .arg(&bin) + .arg("evidence") + .output() + .unwrap(); + assert!(installed.status.success(), "{installed:?}"); + let listed = Command::new(bin.join("rstack")) + .arg("--install-root") + .arg(&root) + .arg("--bin-dir") + .arg(&bin) + .args(["components", "list", "--format", "json"]) + .output() + .unwrap(); + assert!(listed.status.success(), "{listed:?}"); + let listing: serde_json::Value = serde_json::from_slice(&listed.stdout).unwrap(); + assert_eq!(listing["schema_version"], "registry.rstack.components.v1"); + assert_eq!(listing["release"], "v0.44.0"); + let components = listing["components"].as_array().unwrap(); + assert_eq!(components.len(), 9); + let evidence = components + .iter() + .find(|component| component["name"] == "evidence") + .unwrap(); + assert_eq!(evidence["installed"], true); + assert_eq!(evidence["available"], true); + assert_eq!(evidence["version"], "0.44.0"); + assert!(evidence["path"] + .as_str() + .unwrap() + .ends_with("/components/evidence/evidencectl/evidencectl")); + let coordinator = components + .iter() + .find(|component| component["name"] == "coordinator") + .unwrap(); + assert_eq!(coordinator["installed"], false); + assert_eq!(coordinator["available"], false); + assert!(coordinator["version"].is_null()); + assert!(coordinator["path"].is_null()); + + let human = Command::new(bin.join("rstack")) + .arg("--install-root") + .arg(&root) + .arg("--bin-dir") + .arg(&bin) + .args(["components", "list"]) + .output() + .unwrap(); + assert!(human.status.success(), "{human:?}"); + let human = String::from_utf8(human.stdout).unwrap(); + assert!(human.contains("evidence: installed, version 0.44.0, path ")); + assert!(human.contains("coordinator: unavailable, version unavailable, path none")); + let output = Command::new(bin.join("rstack")) + .args([ + "--install-root", + root.to_str().unwrap(), + "--bin-dir", + bin.to_str().unwrap(), + "evidence", + "check", + "a b", + "--format", + "json", + ]) + .current_dir(scratch.path()) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(7), "{output:?}"); + assert!(output.stderr.is_empty(), "{output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let current = root.join("current").canonicalize().unwrap(); + assert!(stdout.contains(&format!("generation:{}\n", current.display()))); + assert!(stdout.contains("release:v0.44.0\n")); + assert!(stdout.contains(&format!( + "selected-evidencectl:{}\n", + current + .join("components/evidence/evidencectl/evidencectl") + .display() + ))); + assert!(stdout.contains("selected-bregctl:\n")); + assert!( + stdout.contains(&format!( + "cwd:{}\n", + scratch.path().canonicalize().unwrap().display() + )), + "{stdout}" + ); + assert!( + stdout.ends_with("arg:check\narg:a b\narg:--format\narg:json\n"), + "{stdout}" + ); + + let delimited = Command::new(bin.join("rstack")) + .args([ + "--install-root", + root.to_str().unwrap(), + "--bin-dir", + bin.to_str().unwrap(), + "--format", + "json", + "evidence", + "check", + "--", + "literal", + ]) + .output() + .unwrap(); + assert_eq!(delimited.status.code(), Some(7), "{delimited:?}"); + let stdout = String::from_utf8(delimited.stdout).unwrap(); + assert!( + stdout.ends_with("arg:check\narg:--format\narg:json\narg:--\narg:literal\n"), + "{stdout}" + ); +} diff --git a/crates/registry-rstack/tests/manager.rs b/crates/registry-rstack/tests/manager.rs new file mode 100644 index 0000000000..68b2d706b3 --- /dev/null +++ b/crates/registry-rstack/tests/manager.rs @@ -0,0 +1,791 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use registry_rstack::install::{ + bootstrap, install, read_selection, uninstall, uninstall_all, update, BootstrapOptions, + ManagerErrorClass, ManagerOptions, +}; +use registry_rstack::GenerationStore; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn scratch() -> tempfile::TempDir { + if let Ok(target) = std::env::var("CARGO_TARGET_DIR") { + tempfile::tempdir_in(target).unwrap() + } else { + tempfile::tempdir().unwrap() + } +} + +fn digest(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn target_platform() -> &'static str { + registry_rstack::install::platform().unwrap() +} + +fn other_platform() -> &'static str { + match target_platform() { + "linux-amd64" => "linux-arm64", + _ => "linux-amd64", + } +} + +fn asset_name(binary: &str, platform: &str) -> String { + asset_name_for(binary, "v0.44.0", platform) +} + +fn asset_name_for(binary: &str, release: &str, platform: &str) -> String { + format!( + "{binary}-{release}-{platform}{}", + if platform == "macos-arm64" { + ".tar.gz" + } else { + "" + } + ) +} + +fn write_versioned_executable(path: &Path, binary: &str, version: &str) { + fs::write( + path, + format!("#!/usr/bin/env sh\nprintf '%s\\n' '{binary} {version}'\n"), + ) + .unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +fn component_asset(directory: &Path, binary: &str, platform: &str) -> (String, Vec) { + component_asset_for(directory, binary, "v0.44.0", platform) +} + +fn component_asset_for( + directory: &Path, + binary: &str, + release: &str, + platform: &str, +) -> (String, Vec) { + let name = asset_name_for(binary, release, platform); + let path = directory.join(&name); + if platform == "macos-arm64" { + let contents = directory.join(format!("archive-{binary}-{release}")); + fs::create_dir(&contents).unwrap(); + let archived_binary = name.strip_suffix(".tar.gz").unwrap(); + write_versioned_executable( + &contents.join(archived_binary), + binary, + release.strip_prefix('v').unwrap(), + ); + fs::write(contents.join("THIRD_PARTY_NOTICES"), "test fixture\n").unwrap(); + fs::write( + contents.join(format!("libaws_lc_fips_{binary}.dylib")), + "test fixture\n", + ) + .unwrap(); + let result = Command::new("tar") + .args(["-czf"]) + .arg(&path) + .arg("-C") + .arg(&contents) + .args([ + archived_binary, + "THIRD_PARTY_NOTICES", + &format!("libaws_lc_fips_{binary}.dylib"), + ]) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + } else { + write_versioned_executable(&path, binary, release.strip_prefix('v').unwrap()); + } + (name, fs::read(path).unwrap()) +} + +struct Fixture { + _workspace: tempfile::TempDir, + assets: PathBuf, + install_root: PathBuf, + bin_dir: PathBuf, + manifest: PathBuf, + checksums: PathBuf, + launcher: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let workspace = scratch(); + let assets = workspace.path().join("assets"); + fs::create_dir(&assets).unwrap(); + let platform = target_platform(); + let launcher_name = asset_name("rstack", platform); + let launcher = assets.join(&launcher_name); + if platform == "macos-arm64" { + let contents = assets.join("archive-rstack"); + fs::create_dir(&contents).unwrap(); + fs::copy( + env!("CARGO_BIN_EXE_rstack"), + contents.join(launcher_name.strip_suffix(".tar.gz").unwrap()), + ) + .unwrap(); + fs::write(contents.join("THIRD_PARTY_NOTICES"), "test fixture\n").unwrap(); + fs::write( + contents.join("libaws_lc_fips_rstack.dylib"), + "test fixture\n", + ) + .unwrap(); + let result = Command::new("tar") + .args(["-czf"]) + .arg(&launcher) + .arg("-C") + .arg(&contents) + .args([ + launcher_name.strip_suffix(".tar.gz").unwrap(), + "THIRD_PARTY_NOTICES", + "libaws_lc_fips_rstack.dylib", + ]) + .output() + .unwrap(); + assert!(result.status.success(), "{result:?}"); + } else { + fs::copy(env!("CARGO_BIN_EXE_rstack"), &launcher).unwrap(); + } + + let mut payloads = Vec::new(); + let launcher_bytes = fs::read(&launcher).unwrap(); + payloads.push(json!({ + "name": launcher_name, + "kind": "binary", + "size": launcher_bytes.len(), + "sha256": digest(&launcher_bytes), + })); + + let mut component_documents = BTreeMap::new(); + for (component, binary, dependencies, component_platform) in [ + ("breg", "bregctl", Vec::<&str>::new(), platform), + ("casework", "caseworkctl", vec!["breg"], platform), + ("evidence", "evidencectl", Vec::<&str>::new(), platform), + ( + "scheduling", + "schedulingctl", + Vec::<&str>::new(), + other_platform(), + ), + ] { + let (name, bytes) = component_asset(&assets, binary, component_platform); + payloads.push(json!({ + "name": name, + "kind": "binary", + "size": bytes.len(), + "sha256": digest(&bytes), + })); + component_documents.insert( + component, + json!({ + "entrypoint": binary, + "dependencies": dependencies, + "platforms": {(component_platform): { + "assets": [{ + "name": name, + "size": bytes.len(), + "sha256": digest(&bytes), + }], + "capabilities": {"tooling": true, "runtime": false}, + }}, + }), + ); + } + + let manifest_name = "registry-stack-v0.44.0-release-manifest.json"; + let manifest = assets.join(manifest_name); + let document = json!({ + "schema_version": "registry-stack.release-candidate.v2", + "repository": "registrystack/registry-stack", + "release": { + "tag": "v0.44.0", + "version": "0.44.0", + "source_sha": "a".repeat(40), + "release_id": "test", + }, + "payloads": payloads, + "components": { + "schema_version": "registry-stack.components.v1", + "release": "v0.44.0", + "launcher": {(platform): { + "name": launcher_name, + "size": launcher_bytes.len(), + "sha256": digest(&launcher_bytes), + }}, + "components": component_documents, + }, + }); + let manifest_bytes = serde_json::to_vec(&document).unwrap(); + fs::write(&manifest, &manifest_bytes).unwrap(); + let checksums = assets.join("SHA256SUMS"); + let mut checksum_lines = Vec::new(); + for payload in document["payloads"].as_array().unwrap() { + checksum_lines.push(format!( + "{} {}", + payload["sha256"].as_str().unwrap(), + payload["name"].as_str().unwrap() + )); + } + checksum_lines.push(format!("{} {manifest_name}", digest(&manifest_bytes))); + fs::write(&checksums, format!("{}\n", checksum_lines.join("\n"))).unwrap(); + let install_root = workspace.path().join("lib/registrystack"); + let bin_dir = workspace.path().join("bin"); + Self { + _workspace: workspace, + assets, + install_root, + bin_dir, + manifest, + checksums, + launcher, + } + } + + fn bootstrap(&self, components: &[&str]) { + bootstrap(BootstrapOptions { + release_manifest: self.manifest.clone(), + checksum_file: self.checksums.clone(), + launcher_asset: self.launcher.clone(), + asset_dir: Some(self.assets.clone()), + install_root: self.install_root.clone(), + bin_dir: self.bin_dir.clone(), + components: components.iter().map(|name| (*name).to_owned()).collect(), + all: false, + }) + .unwrap(); + } + + fn manager_options(&self) -> ManagerOptions { + ManagerOptions { + install_root: self.install_root.clone(), + bin_dir: self.bin_dir.clone(), + asset_dir: Some(self.assets.clone()), + } + } + + fn store(&self) -> GenerationStore { + GenerationStore::new(&self.install_root, &self.bin_dir) + } + + fn write_release( + &self, + release: &str, + evidence_depends_on_breg: bool, + evidence_available: bool, + ) { + let platform = target_platform(); + let launcher_name = asset_name_for("rstack", release, platform); + let (_, launcher_bytes) = component_asset_for(&self.assets, "rstack", release, platform); + let mut payloads = vec![json!({ + "name": launcher_name, + "kind": "binary", + "size": launcher_bytes.len(), + "sha256": digest(&launcher_bytes), + })]; + let mut components = BTreeMap::new(); + for (component, binary, dependencies, available) in [ + ("breg", "bregctl", Vec::<&str>::new(), true), + ("casework", "caseworkctl", vec!["breg"], true), + ( + "evidence", + "evidencectl", + if evidence_depends_on_breg { + vec!["breg"] + } else { + Vec::new() + }, + evidence_available, + ), + ] { + let component_platform = if available { + platform + } else { + other_platform() + }; + let (name, bytes) = + component_asset_for(&self.assets, binary, release, component_platform); + payloads.push(json!({ + "name": name, + "kind": "binary", + "size": bytes.len(), + "sha256": digest(&bytes), + })); + components.insert( + component, + json!({ + "entrypoint": binary, + "dependencies": dependencies, + "platforms": {(component_platform): { + "assets": [{ + "name": name, + "size": bytes.len(), + "sha256": digest(&bytes), + }], + "capabilities": {"tooling": true, "runtime": false}, + }}, + }), + ); + } + let manifest_name = format!("registry-stack-{release}-release-manifest.json"); + let manifest = json!({ + "schema_version": "registry-stack.release-candidate.v2", + "repository": "registrystack/registry-stack", + "release": { + "tag": release, + "version": release.strip_prefix('v').unwrap(), + "source_sha": "b".repeat(40), + "release_id": format!("test-{release}"), + }, + "payloads": payloads, + "components": { + "schema_version": "registry-stack.components.v1", + "release": release, + "launcher": {(platform): { + "name": launcher_name, + "size": launcher_bytes.len(), + "sha256": digest(&launcher_bytes), + }}, + "components": components, + }, + }); + let manifest_bytes = serde_json::to_vec(&manifest).unwrap(); + fs::write(self.assets.join(&manifest_name), &manifest_bytes).unwrap(); + let mut checksum_lines = Vec::new(); + for payload in manifest["payloads"].as_array().unwrap() { + checksum_lines.push(format!( + "{} {}", + payload["sha256"].as_str().unwrap(), + payload["name"].as_str().unwrap() + )); + } + checksum_lines.push(format!("{} {manifest_name}", digest(&manifest_bytes))); + fs::write( + self.assets.join("SHA256SUMS"), + format!("{}\n", checksum_lines.join("\n")), + ) + .unwrap(); + } +} + +#[test] +fn update_without_an_active_installation_is_a_domain_refusal() { + let fixture = Fixture::new(); + + for preview in [true, false] { + let error = update(&fixture.manager_options(), "v0.45.0", preview).unwrap_err(); + assert_eq!(error.class, ManagerErrorClass::Domain); + assert!(error.message.contains("no installation is selected")); + } +} + +#[test] +fn install_adds_dependency_closure_and_retains_installed_components() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + fs::remove_file( + fixture + .assets + .join(asset_name("evidencectl", target_platform())), + ) + .unwrap(); + + let report = install(fixture.manager_options(), vec!["casework".into()], false).unwrap(); + + assert_ne!(report.generation, before_name); + assert_eq!(report.release, "v0.44.0"); + assert_eq!(report.components, vec!["breg", "casework", "evidence"]); + let current = fixture.store().acquire_current().unwrap(); + assert_eq!( + read_selection(current.path()).unwrap().components, + report.components + ); + assert!(current + .path() + .join("components/breg/bregctl/bregctl") + .exists()); + assert!(current + .path() + .join("components/casework/caseworkctl/caseworkctl") + .exists()); + assert!(current + .path() + .join("components/evidence/evidencectl/evidencectl") + .exists()); +} + +#[test] +fn unavailable_mixed_install_refuses_before_staging_or_activation() { + let fixture = Fixture::new(); + fixture.bootstrap(&[]); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + + let error = install( + fixture.manager_options(), + vec!["breg".into(), "scheduling".into()], + false, + ) + .unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Domain); + assert!( + error.message.contains("unavailable"), + "unexpected error: {error}" + ); + let current = fixture.store().acquire_current().unwrap(); + assert_eq!(current.generation().name(), before_name); + assert_eq!( + fs::read_dir(fixture.store().generations_dir()) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn failed_asset_fetch_preserves_the_active_generation() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + fs::write( + fixture + .assets + .join(asset_name("caseworkctl", target_platform())), + "corrupt", + ) + .unwrap(); + + let error = install(fixture.manager_options(), vec!["casework".into()], false).unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Operational); + assert!( + error.message.contains("does not match"), + "unexpected error: {error}" + ); + let current = fixture.store().acquire_current().unwrap(); + assert_eq!(current.generation().name(), before_name); + assert_eq!( + read_selection(current.path()).unwrap().components, + vec!["evidence"] + ); +} + +#[test] +fn uninstall_refuses_a_dependency_of_a_retained_component() { + let fixture = Fixture::new(); + fixture.bootstrap(&["casework", "evidence"]); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + + let error = uninstall(fixture.manager_options(), vec!["breg".into()]).unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Domain); + assert!( + error.message.contains("required by casework"), + "unexpected error: {error}" + ); + assert_eq!( + fixture + .store() + .acquire_current() + .unwrap() + .generation() + .name(), + before_name + ); +} + +#[test] +fn uninstall_activates_a_complete_generation_without_the_component() { + let fixture = Fixture::new(); + fixture.bootstrap(&["casework", "evidence"]); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + + let report = uninstall(fixture.manager_options(), vec!["evidence".into()]).unwrap(); + + assert_ne!(report.generation, before_name); + assert_eq!(report.removed, vec!["evidence"]); + assert_eq!(report.components, vec!["breg", "casework"]); + let current = fixture.store().acquire_current().unwrap(); + assert_eq!( + read_selection(current.path()).unwrap().components, + report.components + ); + assert!(current.path().join("components/breg").exists()); + assert!(current.path().join("components/casework").exists()); + assert!(!current.path().join("components/evidence").exists()); +} + +#[test] +fn full_uninstall_refuses_while_an_invocation_holds_a_generation_lease() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + let invocation = fixture.store().acquire_current().unwrap(); + + let error = uninstall_all(fixture.manager_options()).unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Domain); + assert!( + error.message.contains("in use"), + "unexpected error: {error}" + ); + assert!(fixture.bin_dir.join("rstack").exists()); + assert!(fixture.install_root.join("current").exists()); + assert!(invocation.path().exists()); +} + +#[test] +fn full_uninstall_removes_only_owned_installation_entries() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + fs::write(fixture.bin_dir.join("keep"), "unrelated").unwrap(); + let project = fixture._workspace.path().join("project"); + fs::create_dir(&project).unwrap(); + fs::write(project.join("credentials.json"), "keep").unwrap(); + + let report = uninstall_all(fixture.manager_options()).unwrap(); + + assert_eq!(report.removed_generations.len(), 1); + assert!(!fixture.bin_dir.join("rstack").exists()); + assert!(!fixture.install_root.join("current").exists()); + assert!(!fixture.install_root.join("generations").exists()); + assert_eq!( + fs::read(fixture.bin_dir.join("keep")).unwrap(), + b"unrelated" + ); + assert_eq!(fs::read(project.join("credentials.json")).unwrap(), b"keep"); +} + +#[test] +fn full_uninstall_refuses_changed_or_unrelated_managed_paths_before_removal() { + let modified = Fixture::new(); + modified.bootstrap(&["evidence"]); + let current = modified.store().acquire_current().unwrap(); + let changed = current + .path() + .join("components/evidence/evidencectl/evidencectl"); + drop(current); + fs::write(&changed, "changed").unwrap(); + + let error = uninstall_all(modified.manager_options()).unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Operational); + assert!( + error.message.contains("changed"), + "unexpected error: {error}" + ); + assert!(modified.bin_dir.join("rstack").exists()); + assert!(modified.install_root.join("current").exists()); + assert_eq!(fs::read(changed).unwrap(), b"changed"); + + let unrelated = Fixture::new(); + unrelated.bootstrap(&[]); + fs::write(unrelated.install_root.join("keep"), "unrelated").unwrap(); + + let error = uninstall_all(unrelated.manager_options()).unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Operational); + assert!( + error.message.contains("unrelated"), + "unexpected error: {error}" + ); + assert_eq!( + fs::read(unrelated.install_root.join("keep")).unwrap(), + b"unrelated" + ); + assert!(unrelated.bin_dir.join("rstack").exists()); + assert!(unrelated.install_root.join("current").exists()); +} + +#[test] +fn full_uninstall_recovers_an_interrupted_owned_removal() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + let current = fixture.store().acquire_current().unwrap(); + let generation_name = current.generation().name().to_owned(); + let generation_path = current.path().to_owned(); + drop(current); + let removal_root = fixture.install_root.join(".removing"); + fs::create_dir(&removal_root).unwrap(); + fs::remove_file(fixture.bin_dir.join("rstack")).unwrap(); + fs::remove_file(fixture.install_root.join("current")).unwrap(); + let recovery_path = removal_root.join(&generation_name); + fs::rename(generation_path, &recovery_path).unwrap(); + fs::remove_file(recovery_path.join("components/evidence/evidencectl/evidencectl")).unwrap(); + + let report = uninstall_all(fixture.manager_options()).unwrap(); + + assert_eq!(report.removed_generations, vec![generation_name]); + assert!(!fixture.install_root.join("generations").exists()); + assert!(!fixture.install_root.join(".removing").exists()); +} + +#[test] +fn update_preview_reports_dependency_changes_without_mutation() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + fixture.write_release("v0.45.0", true, true); + + let report = update(&fixture.manager_options(), "v0.45.0", true).unwrap(); + + assert_eq!(report.current_release, "v0.44.0"); + assert_eq!(report.target_release, "v0.45.0"); + assert_eq!(report.components, vec!["breg", "evidence"]); + assert_eq!(report.added_components, vec!["breg"]); + assert!(report.download_bytes > 0); + assert!(report.preview); + assert!(!report.activated); + assert_eq!(report.generation, None); + assert_eq!( + fixture + .store() + .acquire_current() + .unwrap() + .generation() + .name(), + before_name + ); + assert_eq!( + fs::read_dir(fixture.store().generations_dir()) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn update_activates_complete_target_release_with_installed_set_and_new_dependencies() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + fixture.write_release("v0.45.0", true, true); + + let report = update(&fixture.manager_options(), "v0.45.0", false).unwrap(); + + assert_eq!(report.current_release, "v0.44.0"); + assert_eq!(report.target_release, "v0.45.0"); + assert_eq!(report.components, vec!["breg", "evidence"]); + assert_eq!(report.added_components, vec!["breg"]); + assert!(report.activated); + assert!(!report.preview); + let current = fixture.store().acquire_current().unwrap(); + assert_eq!(current.generation().name(), report.generation.unwrap()); + let selection = read_selection(current.path()).unwrap(); + assert_eq!(selection.release, "v0.45.0"); + assert_eq!(selection.components, vec!["breg", "evidence"]); + assert!(current + .path() + .join("components/breg/bregctl/bregctl") + .exists()); + assert!(current + .path() + .join("components/evidence/evidencectl/evidencectl") + .exists()); + let version = Command::new(current.path().join("rstack")) + .arg("--version") + .output() + .unwrap(); + assert!(version.status.success()); + assert_eq!(version.stdout, b"rstack 0.45.0\n"); +} + +#[test] +fn update_refuses_downgrade_and_unavailable_installed_component() { + let downgrade = Fixture::new(); + downgrade.bootstrap(&["evidence"]); + let before = downgrade.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + + let error = update(&downgrade.manager_options(), "v0.43.0", false).unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Domain); + assert!( + error.message.contains("downgrade"), + "unexpected error: {error}" + ); + assert_eq!( + downgrade + .store() + .acquire_current() + .unwrap() + .generation() + .name(), + before_name + ); + + let unavailable = Fixture::new(); + unavailable.bootstrap(&["evidence"]); + unavailable.write_release("v0.45.0", false, false); + + let error = update(&unavailable.manager_options(), "v0.45.0", false).unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Domain); + assert!( + error.message.contains("unavailable"), + "unexpected error: {error}" + ); + assert_eq!( + read_selection(unavailable.store().acquire_current().unwrap().path()) + .unwrap() + .release, + "v0.44.0" + ); +} + +#[test] +fn corrupt_target_payload_preserves_current_release() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + fixture.write_release("v0.45.0", false, true); + fs::write( + fixture + .assets + .join(asset_name_for("evidencectl", "v0.45.0", target_platform())), + "corrupt", + ) + .unwrap(); + + let error = update(&fixture.manager_options(), "v0.45.0", false).unwrap_err(); + + assert_eq!(error.class, ManagerErrorClass::Operational); + assert!( + error.message.contains("does not match"), + "unexpected error: {error}" + ); + assert_eq!( + fixture + .store() + .acquire_current() + .unwrap() + .generation() + .name(), + before_name + ); +} diff --git a/crates/registry-rstack/tests/release_manifest.rs b/crates/registry-rstack/tests/release_manifest.rs new file mode 100644 index 0000000000..75991f9c98 --- /dev/null +++ b/crates/registry-rstack/tests/release_manifest.rs @@ -0,0 +1,68 @@ +use registry_rstack::ReleaseManifest; + +fn document() -> serde_json::Value { + serde_json::json!({ + "schema_version":"registry-stack.release-candidate.v2", + "repository":"registrystack/registry-stack", + "release":{"tag":"v0.44.0","version":"0.44.0","source_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","release_id":"beta-56"}, + "payloads":[ + {"name":"rstack-v0.44.0-linux-amd64","kind":"binary","size":8,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + {"name":"bregctl-v0.44.0-linux-amd64","kind":"binary","size":11,"sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"} + ], + "components":{ + "schema_version":"registry-stack.components.v1", + "release":"v0.44.0", + "launcher":{"linux-amd64":{"name":"rstack-v0.44.0-linux-amd64","size":8,"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}, + "components":{"breg":{"entrypoint":"bregctl","dependencies":[],"platforms":{"linux-amd64":{"assets":[{"name":"bregctl-v0.44.0-linux-amd64","size":11,"sha256":"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}],"capabilities":{"tooling":true,"runtime":false}}}}} + } + }) +} + +#[test] +fn release_manifest_binds_component_assets_to_published_digests() { + let valid = serde_json::to_vec(&document()).unwrap(); + assert_eq!( + ReleaseManifest::parse(&valid).unwrap().release.tag, + "v0.44.0" + ); + + let mut tampered = document(); + tampered["components"]["components"]["breg"]["platforms"]["linux-amd64"]["assets"][0] + ["sha256"] = serde_json::json!("d".repeat(64)); + let error = ReleaseManifest::parse(&serde_json::to_vec(&tampered).unwrap()).unwrap_err(); + assert!(error.contains("payload")); + + let mut mismatched_release = document(); + mismatched_release["components"]["release"] = serde_json::json!("v0.45.0"); + assert!(ReleaseManifest::parse(&serde_json::to_vec(&mismatched_release).unwrap()).is_err()); +} + +#[test] +fn duplicate_payload_name_is_rejected() { + let mut value = document(); + let duplicate = value["payloads"][0].clone(); + value["payloads"].as_array_mut().unwrap().push(duplicate); + assert!(ReleaseManifest::parse(&serde_json::to_vec(&value).unwrap()).is_err()); +} + +#[test] +fn duplicate_component_document_member_is_rejected() { + let raw = serde_json::to_string(&document()).unwrap(); + let needle = "\"launcher\":{\"linux-amd64\":"; + assert!(raw.contains(needle)); + let ambiguous = raw.replacen( + needle, + "\"launcher\":{\"linux-amd64\":null,\"linux-amd64\":", + 1, + ); + assert!(ReleaseManifest::parse(ambiguous.as_bytes()).is_err()); +} + +#[test] +fn release_identity_must_use_canonical_version_text() { + let mut value = document(); + value["release"]["tag"] = serde_json::json!("v00.44.0"); + value["release"]["version"] = serde_json::json!("00.44.0"); + value["components"]["release"] = serde_json::json!("v00.44.0"); + assert!(ReleaseManifest::parse(&serde_json::to_vec(&value).unwrap()).is_err()); +} diff --git a/crates/registry-rstack/tests/store.rs b/crates/registry-rstack/tests/store.rs new file mode 100644 index 0000000000..a9995514a7 --- /dev/null +++ b/crates/registry-rstack/tests/store.rs @@ -0,0 +1,298 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use registry_rstack::{GenerationStore, PreserveReason, PublicLinkStatus}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::os::unix::fs::{symlink, PermissionsExt}; +use std::path::Path; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +fn new_store(root: &Path) -> GenerationStore { + GenerationStore::new(root.join("lib/registrystack"), root.join("bin")) +} + +fn write_generation(stage: &Path, marker: &str) { + let launcher = stage.join("rstack"); + fs::write(&launcher, format!("#!/bin/sh\nprintf '%s' '{marker}'\n")).unwrap(); + let mut permissions = fs::metadata(&launcher).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&launcher, permissions).unwrap(); + fs::write( + stage.join("manifest.json"), + format!(r#"{{"release":"{marker}"}}"#), + ) + .unwrap(); + fs::create_dir(stage.join("components")).unwrap(); + fs::write(stage.join("components/tool"), marker).unwrap(); +} + +fn publish( + mutation: ®istry_rstack::MutationGuard<'_>, + name: &str, + marker: &str, +) -> registry_rstack::Generation { + let stage = mutation.stage_generation(name).unwrap(); + write_generation(stage.path(), marker); + stage.publish_preverified().unwrap() +} + +#[test] +fn read_only_selection_does_not_create_an_absent_installation() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + let install_root = store.install_root().to_owned(); + + assert!(!store.has_installation().unwrap()); + let error = store.acquire_current().unwrap_err(); + assert!(error.contains("no Registry Stack installation")); + assert!(!install_root.exists()); +} + +#[test] +fn publishes_complete_generation_and_atomically_selects_it() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + + let mutation = store.lock_mutation().unwrap(); + assert!(mutation.current().unwrap().is_none()); + let generation = publish(&mutation, "v1.2.3-0001", "old"); + assert!(store.current_link().symlink_metadata().is_err()); + mutation.activate(&generation).unwrap(); + assert_eq!( + mutation.current().unwrap().unwrap().name(), + generation.name() + ); + drop(mutation); + + assert!(store.has_installation().unwrap()); + let lease = store.acquire_current().unwrap(); + assert_eq!(lease.generation().name(), "v1.2.3-0001"); + assert_eq!(lease.path(), generation.path()); + assert_eq!( + fs::read_to_string(lease.path().join("components/tool")).unwrap(), + "old" + ); + assert!(lease.lease_fd() >= 0); +} + +#[test] +fn incomplete_stage_never_changes_current_selection() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + let mutation = store.lock_mutation().unwrap(); + let old = publish(&mutation, "v1.2.3-0001", "old"); + mutation.activate(&old).unwrap(); + + let incomplete_path = { + let stage = mutation.stage_generation("v1.2.3-0002").unwrap(); + fs::write(stage.path().join("manifest.json"), "{}").unwrap(); + let path = stage.path().to_owned(); + let error = stage.publish_preverified().unwrap_err(); + assert!(error.contains("rstack"), "unexpected error: {error}"); + path + }; + assert!( + !incomplete_path.exists(), + "failed staging directory remained" + ); + drop(mutation); + + assert_eq!( + store.acquire_current().unwrap().generation().name(), + old.name() + ); +} + +#[test] +fn serializes_mutations_across_store_instances() { + let temporary = tempfile::tempdir().unwrap(); + let install_root = temporary.path().join("lib/registrystack"); + let bin_dir = temporary.path().join("bin"); + let first = GenerationStore::new(&install_root, &bin_dir); + let guard = first.lock_mutation().unwrap(); + + let (sender, receiver) = mpsc::channel(); + let worker = thread::spawn(move || { + let second = GenerationStore::new(install_root, bin_dir); + let _guard = second.lock_mutation().unwrap(); + sender.send(()).unwrap(); + }); + assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); + drop(guard); + receiver.recv_timeout(Duration::from_secs(2)).unwrap(); + worker.join().unwrap(); +} + +#[test] +fn public_link_is_idempotent_but_never_replaces_an_unrelated_entry() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + let mutation = store.lock_mutation().unwrap(); + assert_eq!( + mutation.ensure_public_link().unwrap(), + PublicLinkStatus::Created + ); + assert_eq!( + mutation.ensure_public_link().unwrap(), + PublicLinkStatus::AlreadyOwned + ); + let public_link = temporary.path().join("bin/rstack"); + assert_eq!( + fs::read_link(&public_link).unwrap(), + store.current_link().join("rstack") + ); + drop(mutation); + + let unrelated_root = tempfile::tempdir().unwrap(); + let unrelated_store = new_store(unrelated_root.path()); + fs::create_dir_all(unrelated_root.path().join("bin")).unwrap(); + fs::write(unrelated_root.path().join("bin/rstack"), "mine").unwrap(); + let mutation = unrelated_store.lock_mutation().unwrap(); + let error = mutation.ensure_public_link().unwrap_err(); + assert!(error.contains("unrelated"), "unexpected error: {error}"); + assert_eq!( + fs::read(unrelated_root.path().join("bin/rstack")).unwrap(), + b"mine" + ); + + let symlink_root = tempfile::tempdir().unwrap(); + let symlink_store = new_store(symlink_root.path()); + fs::create_dir_all(symlink_root.path().join("bin")).unwrap(); + symlink("somewhere-else", symlink_root.path().join("bin/rstack")).unwrap(); + let mutation = symlink_store.lock_mutation().unwrap(); + assert!(mutation + .ensure_public_link() + .unwrap_err() + .contains("unrelated")); + assert_eq!( + fs::read_link(symlink_root.path().join("bin/rstack")).unwrap(), + Path::new("somewhere-else") + ); +} + +#[test] +fn activation_refuses_an_unrelated_current_entry() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + let mutation = store.lock_mutation().unwrap(); + let generation = publish(&mutation, "v1.2.3-0001", "new"); + fs::write(store.current_link(), "mine").unwrap(); + + let error = mutation.activate(&generation).unwrap_err(); + assert!(error.contains("unrelated"), "unexpected error: {error}"); + assert_eq!(fs::read(store.current_link()).unwrap(), b"mine"); +} + +#[test] +fn current_and_invocation_refuse_modified_active_generation() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + let mutation = store.lock_mutation().unwrap(); + let modified = publish(&mutation, "v1.2.3-0001", "old"); + mutation.activate(&modified).unwrap(); + fs::write(modified.path().join("components/tool"), "changed").unwrap(); + + let error = mutation.current().unwrap_err(); + assert!(error.contains("changed"), "unexpected error: {error}"); + drop(mutation); + let error = store.acquire_current().unwrap_err(); + assert!(error.contains("changed"), "unexpected error: {error}"); + + let mutation = store.lock_mutation().unwrap(); + let replacement = publish(&mutation, "v1.2.3-0002", "new"); + mutation.activate(&replacement).unwrap(); + assert_eq!( + mutation.current().unwrap().unwrap().name(), + replacement.name() + ); + assert_eq!( + fs::read(modified.path().join("components/tool")).unwrap(), + b"changed" + ); +} + +#[test] +fn cleanup_waits_for_the_final_generation_lease() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + let mutation = store.lock_mutation().unwrap(); + let old = publish(&mutation, "v1.2.3-0001", "old"); + mutation.activate(&old).unwrap(); + drop(mutation); + + let old_lease = store.acquire_current().unwrap(); + let mutation = store.lock_mutation().unwrap(); + let new = publish(&mutation, "v1.2.3-0002", "new"); + mutation.activate(&new).unwrap(); + let report = mutation.cleanup().unwrap(); + assert!(report.removed.is_empty()); + assert_eq!(report.reason_for(old.name()), Some(PreserveReason::InUse)); + assert_eq!(report.reason_for(new.name()), Some(PreserveReason::Active)); + assert!(old.path().exists()); + drop(mutation); + + drop(old_lease); + let mutation = store.lock_mutation().unwrap(); + let report = mutation.cleanup().unwrap(); + assert_eq!(report.removed, vec![old.name().to_owned()]); + assert!(!old.path().exists()); + assert!(new.path().exists()); +} + +#[test] +fn stale_pid_text_cannot_keep_an_unlocked_generation_alive() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + let mutation = store.lock_mutation().unwrap(); + let old = publish(&mutation, "v1.2.3-0001", "old"); + let new = publish(&mutation, "v1.2.3-0002", "new"); + mutation.activate(&new).unwrap(); + let mut lease_record = OpenOptions::new() + .write(true) + .open(old.path().join(".rstack-lease")) + .unwrap(); + lease_record.write_all(b"999999\n").unwrap(); + drop(lease_record); + + let report = mutation.cleanup().unwrap(); + assert_eq!(report.removed, vec![old.name().to_owned()]); +} + +#[test] +fn cleanup_preserves_modified_and_unowned_generations() { + let temporary = tempfile::tempdir().unwrap(); + let store = new_store(temporary.path()); + let mutation = store.lock_mutation().unwrap(); + let modified = publish(&mutation, "v1.2.3-0001", "old"); + let active = publish(&mutation, "v1.2.3-0002", "new"); + mutation.activate(&active).unwrap(); + fs::write(modified.path().join("components/tool"), "changed").unwrap(); + fs::write(modified.path().join("personal-notes.txt"), "keep this").unwrap(); + let unrelated = store.generations_dir().join("someone-elses-files"); + fs::create_dir(&unrelated).unwrap(); + fs::write(unrelated.join("keep"), "keep").unwrap(); + + let report = mutation.cleanup().unwrap(); + assert!(report.removed.is_empty()); + assert_eq!( + report.reason_for(modified.name()), + Some(PreserveReason::Modified) + ); + assert_eq!( + report.reason_for("someone-elses-files"), + Some(PreserveReason::Unowned) + ); + assert_eq!( + fs::read(modified.path().join("components/tool")).unwrap(), + b"changed" + ); + assert_eq!( + fs::read(modified.path().join("personal-notes.txt")).unwrap(), + b"keep this" + ); + assert_eq!(fs::read(unrelated.join("keep")).unwrap(), b"keep"); +} diff --git a/crates/registry-scheduling-client-node/README.md b/crates/registry-scheduling-client-node/README.md index 24d65ad032..a2e73076df 100644 --- a/crates/registry-scheduling-client-node/README.md +++ b/crates/registry-scheduling-client-node/README.md @@ -18,7 +18,7 @@ a protocol failure. A keyed command (hold, appointment create, reschedule, cancel) whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, -or a 5xx) is resent identically under the same key up to `maxMutationRetries` +or a 5xx) is resent identically under the same key up to `maximumMutationRetries` times: 0 to 2, default 2, and 0 sends it once. Reads and the unkeyed hold release are never resent. When the returned `SchedulingClientError` still reports `outcomeUnknown`, the command may have taken effect: recover by diff --git a/crates/registry-scheduling-client-node/__test__/facade.test-d.ts b/crates/registry-scheduling-client-node/__test__/facade.test-d.ts index d1079e796f..a8c42ff3c4 100644 --- a/crates/registry-scheduling-client-node/__test__/facade.test-d.ts +++ b/crates/registry-scheduling-client-node/__test__/facade.test-d.ts @@ -13,8 +13,16 @@ import { type SchedulingServiceDocument, } from '../client' -const client = new SchedulingClient({ baseUrl: 'https://scheduling.example.test/' }) -const once = new SchedulingClient({ baseUrl: 'https://scheduling.example.test/', maxMutationRetries: 0 }) +const client = new SchedulingClient({ + baseUrl: 'https://scheduling.example.test/', + maximumResponseBytes: 1500, + trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----', +}) +// @ts-expect-error the public response-bound name is maximumResponseBytes +new SchedulingClient({ baseUrl: 'https://scheduling.example.test/', maxResponseBytes: 1500 }) +// @ts-expect-error null is not an omission in JavaScript configuration +new SchedulingClient({ baseUrl: 'https://scheduling.example.test/', attemptTimeoutMilliseconds: null }) +const once = new SchedulingClient({ baseUrl: 'https://scheduling.example.test/', maximumMutationRetries: 0 }) void once const token: string = 'header.payload.signature' const appointmentId = '0199d0e0-8f2a-7c3b-9d4e-5f6a7b8c9d0f' diff --git a/crates/registry-scheduling-client-node/__test__/facade.test.js b/crates/registry-scheduling-client-node/__test__/facade.test.js index e4de3db10b..422396f542 100644 --- a/crates/registry-scheduling-client-node/__test__/facade.test.js +++ b/crates/registry-scheduling-client-node/__test__/facade.test.js @@ -545,7 +545,7 @@ test('an unreachable service is a transport failure', async () => { test('an invalid configuration is a configuration error', () => { const { SchedulingClient, SchedulingClientError } = require('../client'); - for (const config of [{ baseUrl: 'not a url' }, { baseUrl: 'https://scheduling.example/', maxResponseBytes: 0 }]) { + for (const config of [{ baseUrl: 'not a url' }, { baseUrl: 'https://scheduling.example/', maximumResponseBytes: 0 }]) { assert.throws(() => new SchedulingClient(config), (error) => { assert.ok(error instanceof SchedulingClientError); assert.equal(error.kind, 'configuration'); @@ -559,14 +559,13 @@ test('a configuration member outside the declared set is a configuration error', const baseUrl = 'https://scheduling.example/'; assert.ok(new SchedulingClient({ baseUrl, - requestTimeoutMilliseconds: 1500, + attemptTimeoutMilliseconds: 1500, connectTimeoutMilliseconds: 1500, - maxResponseBytes: 1500, + maximumResponseBytes: 1500, userAgent: 'scheduling-test', - trustedRootCertificates: null, - maxMutationRetries: 1, + maximumMutationRetries: 1, })); - for (const member of ['maxMutationRetry', 'requestTimeoutMs', 'authorization']) { + for (const member of ['maxMutationRetries', 'maximumMutationRetry', 'requestTimeoutMilliseconds', 'attemptTimeoutMs', 'maxResponseBytes', 'authorization']) { for (const value of [0, null]) { assert.throws(() => new SchedulingClient({ baseUrl, [member]: value }), (error) => { assert.ok(error instanceof SchedulingClientError, member); @@ -578,17 +577,25 @@ test('a configuration member outside the declared set is a configuration error', } }); -test('every optional configuration member accepts null, as its declaration says', () => { - const { SchedulingClient } = require('../client'); +test('null is not an omission for non-nullable JavaScript configuration', () => { + const { SchedulingClient, SchedulingClientError } = require('../client'); for (const member of [ - 'requestTimeoutMilliseconds', + 'attemptTimeoutMilliseconds', 'connectTimeoutMilliseconds', - 'maxResponseBytes', + 'maximumResponseBytes', 'userAgent', 'trustedRootCertificates', - 'maxMutationRetries', + 'maximumMutationRetries', ]) { - assert.ok(new SchedulingClient({ baseUrl: 'https://scheduling.example/', [member]: null }), member); + assert.throws( + () => new SchedulingClient({ baseUrl: 'https://scheduling.example/', [member]: null }), + (error) => { + assert.ok(error instanceof SchedulingClientError, member); + assert.equal(error.kind, 'configuration', member); + assert.equal(error.outcomeUnknown, false, member); + return true; + }, + ); } }); @@ -598,14 +605,14 @@ test('a timeout or response bound that is not a whole number in range is a confi // A 32-bit conversion would read -1 as 4294967295, 1.5 as 1, 2 ** 32 as 0, // and 2 ** 32 + 1024 as 1024, so each value is checked whole. const accepted = { - requestTimeoutMilliseconds: [1500, 2 ** 32], + attemptTimeoutMilliseconds: [1500, 2 ** 32], connectTimeoutMilliseconds: [1500, 2 ** 32], - maxResponseBytes: [1500], + maximumResponseBytes: [1500], }; const refused = { - requestTimeoutMilliseconds: [-1, 1.5, '1500'], - connectTimeoutMilliseconds: [-1, 1.5, '1500'], - maxResponseBytes: [-1, 1.5, 2 ** 32, 2 ** 32 + 1024, '1500'], + attemptTimeoutMilliseconds: [true, false, -1, 1.5, '1500'], + connectTimeoutMilliseconds: [true, false, -1, 1.5, '1500'], + maximumResponseBytes: [true, false, -1, 1.5, 2 ** 32, 2 ** 32 + 1024, '1500'], }; for (const [field, values] of Object.entries(accepted)) { for (const value of values) { @@ -628,11 +635,12 @@ test('a configuration value the sanitizer refuses is a configuration error', () const { SchedulingClient, SchedulingClientError } = require('../client'); const baseUrl = 'https://scheduling.example/'; for (const [label, config] of [ - ['an unsafe integer', { baseUrl, requestTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], - ['a non-finite number', { baseUrl, maxResponseBytes: Infinity }], + ['an unsafe integer', { baseUrl, attemptTimeoutMilliseconds: Number.MAX_SAFE_INTEGER + 1 }], + ['a non-finite number', { baseUrl, maximumResponseBytes: Infinity }], + ['a binary trust bundle', { baseUrl, trustedRootCertificates: Buffer.from('not PEM text') }], ['an undefined member', { baseUrl, userAgent: undefined }], ['a function', { baseUrl, userAgent: () => 'scheduling-test' }], - ['a date', { baseUrl, requestTimeoutMilliseconds: new Date(0) }], + ['a date', { baseUrl, attemptTimeoutMilliseconds: new Date(0) }], ['a proxy', new Proxy({ baseUrl }, {})], ['no configuration', undefined], ]) { @@ -717,7 +725,7 @@ test('a retry ceiling of zero sends a hold once and reports the outcome unknown' const { client: scheduling, requests } = await retrying( context, [UNAVAILABLE, answered(201, HOLD)], - { maxMutationRetries: 0 }, + { maximumMutationRetries: 0 }, ); const { SchedulingClientError } = require('../client'); @@ -741,7 +749,7 @@ test('a 4xx refusal of a hold is never resent and reports the outcome known', as 'The supply is fully committed for the requested interval. Choose another time.', ), answered(201, HOLD), - ], { maxMutationRetries: 2 }); + ], { maximumMutationRetries: 2 }); await assert.rejects(scheduling.createHold('one-call-secret', 'key-1', ADMISSION), (error) => { assert.equal(error.kind, 'problem'); @@ -754,13 +762,13 @@ test('a 4xx refusal of a hold is never resent and reports the outcome known', as test('a retry ceiling outside zero to two is a configuration error', () => { const { SchedulingClient, SchedulingClientError } = require('../client'); - for (const maxMutationRetries of [0, 1, 2]) { - assert.ok(new SchedulingClient({ baseUrl: 'https://scheduling.example/', maxMutationRetries })); + for (const maximumMutationRetries of [0, 1, 2]) { + assert.ok(new SchedulingClient({ baseUrl: 'https://scheduling.example/', maximumMutationRetries })); } - for (const maxMutationRetries of [3, 255, 256, -1, 1.5, '1']) { - assert.throws(() => new SchedulingClient({ baseUrl: 'https://scheduling.example/', maxMutationRetries }), (error) => { - assert.ok(error instanceof SchedulingClientError, `${maxMutationRetries}`); - assert.equal(error.kind, 'configuration', `${maxMutationRetries}`); + for (const maximumMutationRetries of [true, false, 3, 255, 256, -1, 1.5, '1']) { + assert.throws(() => new SchedulingClient({ baseUrl: 'https://scheduling.example/', maximumMutationRetries }), (error) => { + assert.ok(error instanceof SchedulingClientError, `${maximumMutationRetries}`); + assert.equal(error.kind, 'configuration', `${maximumMutationRetries}`); assert.equal(error.outcomeUnknown, false); return true; }); @@ -792,7 +800,7 @@ for (const [code, status, title, detail] of [ test(`receipt observation maps ${code} without retry or exposing caller values`, async (context) => { const { baseUrl, requests } = await serve(context, () => problem(code, status, title, detail)); const { SchedulingClient, SchedulingClientError } = require('../client'); - const client = new SchedulingClient({ baseUrl, maxMutationRetries: 2 }); + const client = new SchedulingClient({ baseUrl, maximumMutationRetries: 2 }); await assert.rejects(client.appointmentReceipt('token-canary', 'key-canary', { admission: ADMISSION }), (error) => { assert.ok(error instanceof SchedulingClientError); assert.equal(error.kind, 'problem'); diff --git a/crates/registry-scheduling-client-node/client.d.ts b/crates/registry-scheduling-client-node/client.d.ts index 43557bf0ec..04ea7bfc94 100644 --- a/crates/registry-scheduling-client-node/client.d.ts +++ b/crates/registry-scheduling-client-node/client.d.ts @@ -10,11 +10,12 @@ export type JsonValue = JsonScalar | ReadonlyArray | { readonly [key: export interface SchedulingClientConfig { baseUrl: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + /** Additional trust roots for the deployment, as PEM text. */ + trustedRootCertificates?: string /** * How many times a keyed command (hold, appointment create, reschedule, * cancel) whose outcome is unknown is resent, identically and under the @@ -22,7 +23,7 @@ export interface SchedulingClientConfig { * to 2, default 2; 0 sends it once. Any other value is a `configuration` * error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } /** An opaque record link Scheduling retains with a hold or appointment. */ diff --git a/crates/registry-scheduling-client-node/src/lib.rs b/crates/registry-scheduling-client-node/src/lib.rs index 7f23250286..179d53fab0 100644 --- a/crates/registry-scheduling-client-node/src/lib.rs +++ b/crates/registry-scheduling-client-node/src/lib.rs @@ -27,14 +27,14 @@ const INVALID_ARGUMENTS: &str = "Scheduling client arguments are invalid"; #[serde(rename_all = "camelCase", deny_unknown_fields)] struct SchedulingClientConfig { base_url: String, - request_timeout_milliseconds: Option, + attempt_timeout_milliseconds: Option, connect_timeout_milliseconds: Option, - max_response_bytes: Option, + maximum_response_bytes: Option, user_agent: Option, trusted_root_certificates: Option, /// How many times a keyed command whose outcome is unknown is resent under /// the same idempotency key: 0 to 2, default 2, and 0 disables the resend. - max_mutation_retries: Option, + maximum_mutation_retries: Option, } #[napi(object)] @@ -71,6 +71,7 @@ pub struct SchedulingClient { impl SchedulingClient { #[napi(constructor)] pub fn new(config: Value) -> Result { + reject_null_configuration_members(&config)?; let config: SchedulingClientConfig = serde_json::from_value(config).map_err(|_| { binding_error( "configuration", @@ -84,14 +85,14 @@ impl SchedulingClient { ) })?; let mut core = CoreConfig::new(base_url); - if let Some(value) = config.request_timeout_milliseconds { - core = core.with_request_timeout(Duration::from_millis(whole_number(value)?)); + if let Some(value) = config.attempt_timeout_milliseconds { + core = core.with_attempt_timeout(Duration::from_millis(whole_number(value)?)); } if let Some(value) = config.connect_timeout_milliseconds { core = core.with_connect_timeout(Duration::from_millis(whole_number(value)?)); } - if let Some(value) = config.max_response_bytes { - core = core.with_max_response_bytes(whole_number(value)?); + if let Some(value) = config.maximum_response_bytes { + core = core.with_maximum_response_bytes(whole_number(value)?); } if let Some(value) = config.user_agent { core = core.with_user_agent(value); @@ -99,8 +100,8 @@ impl SchedulingClient { if let Some(value) = config.trusted_root_certificates { core = core.with_trusted_root_certificates(value.into_bytes()); } - if let Some(value) = config.max_mutation_retries { - core = core.with_max_mutation_retries(mutation_retries(value)?); + if let Some(value) = config.maximum_mutation_retries { + core = core.with_maximum_mutation_retries(mutation_retries(value)?); } CoreClient::new(core) .map(|inner| Self { inner }) @@ -514,6 +515,21 @@ fn binding_error(kind: &'static str, message: &'static str) -> NapiError { ) } +/// JavaScript `null` is a value, not omission. Optional configuration is +/// omitted by leaving the member absent. +fn reject_null_configuration_members(config: &Value) -> Result<()> { + if config + .as_object() + .is_some_and(|members| members.values().any(Value::is_null)) + { + return Err(binding_error( + "configuration", + "Scheduling client configuration is invalid", + )); + } + Ok(()) +} + /// A millisecond or byte count as a JavaScript number. Only a whole number in /// the safe integer range reaches the client, which applies its own bounds; a /// 32-bit conversion would wrap a negative, fractional, or larger number. diff --git a/crates/registry-scheduling-client-py/README.md b/crates/registry-scheduling-client-py/README.md index df19e57a00..92987bf341 100644 --- a/crates/registry-scheduling-client-py/README.md +++ b/crates/registry-scheduling-client-py/README.md @@ -51,7 +51,7 @@ code outside the closed catalogue is a protocol failure. A keyed command (hold, appointment create, reschedule, cancel) whose outcome is unknown (a timeout or broken exchange after it was sent, an unusable answer, or a 5xx) is resent identically under the same key up to -`max_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. Reads and +`maximum_mutation_retries` times: 0 to 2, default 2, and 0 sends it once. Reads and the unkeyed hold release are never resent. When the returned `SchedulingClientError` still reports `outcome_unknown`, the command may have taken effect: recover by sending it again under the same key, because a new diff --git a/crates/registry-scheduling-client-py/python/registry_scheduling_client/__init__.pyi b/crates/registry-scheduling-client-py/python/registry_scheduling_client/__init__.pyi index f1dbe046b1..aea8f3c35e 100644 --- a/crates/registry-scheduling-client-py/python/registry_scheduling_client/__init__.pyi +++ b/crates/registry-scheduling-client-py/python/registry_scheduling_client/__init__.pyi @@ -247,7 +247,7 @@ class SchedulingClientError(Exception): outcome_unknown: bool class SchedulingClient: - def __init__(self, base_url: str, request_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, max_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, max_mutation_retries: int | None = None) -> None: ... + def __init__(self, base_url: str, attempt_timeout_seconds: float | None = None, connect_timeout_seconds: float | None = None, maximum_response_bytes: int | None = None, user_agent: str | None = None, trusted_root_certificates: bytes | None = None, maximum_mutation_retries: int | None = None) -> None: ... def get_scheduling(self, token: str) -> Complete[SchedulingServiceDocument]: ... def list_services(self, token: str, cursor: str | None = None) -> Complete[PageDocument[ServiceDocument]]: ... def list_offerings(self, token: str, cursor: str | None = None) -> Complete[PageDocument[OfferingDocument]]: ... diff --git a/crates/registry-scheduling-client-py/src/lib.rs b/crates/registry-scheduling-client-py/src/lib.rs index e5db75c799..9fdc531e7a 100644 --- a/crates/registry-scheduling-client-py/src/lib.rs +++ b/crates/registry-scheduling-client-py/src/lib.rs @@ -9,7 +9,7 @@ use chrono::{DateTime, Utc}; use pyo3::{ exceptions::{PyException, PyRuntimeError}, prelude::*, - types::{PyBool, PyDict}, + types::{PyBool, PyDict, PyFloat, PyInt}, }; use scheduling_client_sdk::{ AdmissionRequest, BearerToken, CancelAppointmentRequest, CreateAppointmentRequest, @@ -184,10 +184,14 @@ fn optional_instant(py: Python<'_>, value: Option<&str>) -> PyResult, value: Option) -> PyResult> { +fn page_limit(py: Python<'_>, value: Option<&Bound<'_, PyAny>>) -> PyResult> { value .map(|value| { - u32::try_from(value) + if value.is_instance_of::() { + return Err(binding_error(py, "invalid-request", INVALID_ARGUMENTS)); + } + value + .extract::() .map_err(|_| binding_error(py, "invalid-request", INVALID_ARGUMENTS)) }) .transpose() @@ -214,18 +218,39 @@ fn mutation_retries(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { /// or `float` reaches the client, which refuses zero; a `bool` is not a /// duration. fn duration(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { - let invalid = || { - binding_error( + if value.is_exact_instance_of::() { + let seconds = value.extract::().map_err(|_| { + binding_error( + py, + "configuration", + "Scheduling client configuration is invalid", + ) + })?; + Duration::try_from_secs_f64(seconds).map_err(|_| { + binding_error( + py, + "configuration", + "client timeouts must be finite non-negative seconds", + ) + }) + } else if value.is_exact_instance_of::() { + value + .extract::() + .map(Duration::from_secs) + .map_err(|_| { + binding_error( + py, + "configuration", + "Scheduling client configuration is invalid", + ) + }) + } else { + Err(binding_error( py, "configuration", - "client timeouts must be finite non-negative seconds", - ) - }; - if value.is_instance_of::() { - return Err(invalid()); + "Scheduling client configuration is invalid", + )) } - let seconds = value.extract::().map_err(|_| invalid())?; - Duration::try_from_secs_f64(seconds).map_err(|_| invalid()) } /// The response body bound as a Python integer. Only an `int` in the `u64` @@ -239,7 +264,7 @@ fn response_bytes(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { "Scheduling client configuration is invalid", ) }; - if value.is_instance_of::() { + if !value.is_exact_instance_of::() { return Err(invalid()); } value.extract::().map_err(|_| invalid()) @@ -280,17 +305,17 @@ struct SchedulingClient { #[pymethods] impl SchedulingClient { #[new] - #[pyo3(signature = (base_url, request_timeout_seconds=None, connect_timeout_seconds=None, max_response_bytes=None, user_agent=None, trusted_root_certificates=None, max_mutation_retries=None))] + #[pyo3(signature = (base_url, attempt_timeout_seconds=None, connect_timeout_seconds=None, maximum_response_bytes=None, user_agent=None, trusted_root_certificates=None, maximum_mutation_retries=None))] #[allow(clippy::too_many_arguments)] fn new( py: Python<'_>, base_url: &str, - request_timeout_seconds: Option<&Bound<'_, PyAny>>, + attempt_timeout_seconds: Option<&Bound<'_, PyAny>>, connect_timeout_seconds: Option<&Bound<'_, PyAny>>, - max_response_bytes: Option<&Bound<'_, PyAny>>, + maximum_response_bytes: Option<&Bound<'_, PyAny>>, user_agent: Option, trusted_root_certificates: Option>, - max_mutation_retries: Option<&Bound<'_, PyAny>>, + maximum_mutation_retries: Option<&Bound<'_, PyAny>>, ) -> PyResult { let base_url = Url::parse(base_url).map_err(|_| { binding_error( @@ -300,14 +325,14 @@ impl SchedulingClient { ) })?; let mut config = SchedulingClientConfig::new(base_url); - if let Some(value) = request_timeout_seconds { - config = config.with_request_timeout(duration(py, value)?); + if let Some(value) = attempt_timeout_seconds { + config = config.with_attempt_timeout(duration(py, value)?); } if let Some(value) = connect_timeout_seconds { config = config.with_connect_timeout(duration(py, value)?); } - if let Some(value) = max_response_bytes { - config = config.with_max_response_bytes(response_bytes(py, value)?); + if let Some(value) = maximum_response_bytes { + config = config.with_maximum_response_bytes(response_bytes(py, value)?); } if let Some(value) = user_agent { config = config.with_user_agent(value); @@ -315,8 +340,8 @@ impl SchedulingClient { if let Some(value) = trusted_root_certificates { config = config.with_trusted_root_certificates(value); } - if let Some(value) = max_mutation_retries { - config = config.with_max_mutation_retries(mutation_retries(py, value)?); + if let Some(value) = maximum_mutation_retries { + config = config.with_maximum_mutation_retries(mutation_retries(py, value)?); } let inner = py .detach(|| RustClient::new(config)) @@ -389,7 +414,7 @@ impl SchedulingClient { start: Option<&str>, end: Option<&str>, cursor: Option<&str>, - limit: Option, + limit: Option<&Bound<'_, PyAny>>, ) -> PyResult> { let token = bearer(py, token)?; let start = optional_instant(py, start)?; @@ -537,7 +562,7 @@ impl SchedulingClient { token: &str, reference: &Bound<'_, PyAny>, cursor: Option<&str>, - limit: Option, + limit: Option<&Bound<'_, PyAny>>, ) -> PyResult> { let token = bearer(py, token)?; let reference: ExternalReference = input(py, reference)?; diff --git a/crates/registry-scheduling-client-py/tests/python/test_construction.py b/crates/registry-scheduling-client-py/tests/python/test_construction.py index 978c6b1bc6..b6127641b6 100644 --- a/crates/registry-scheduling-client-py/tests/python/test_construction.py +++ b/crates/registry-scheduling-client-py/tests/python/test_construction.py @@ -28,7 +28,9 @@ def test_constructs_without_retaining_credentials(self) -> None: def test_invalid_configuration_has_a_stable_kind(self) -> None: for build in ( lambda: SchedulingClient("not a URL"), - lambda: SchedulingClient("https://scheduling.example.invalid/", max_response_bytes=0), + lambda: SchedulingClient( + "https://scheduling.example.invalid/", maximum_response_bytes=0 + ), ): with self.assertRaises(SchedulingClientError) as raised: build() @@ -36,36 +38,40 @@ def test_invalid_configuration_has_a_stable_kind(self) -> None: def test_negative_timeout_is_a_configuration_error(self) -> None: with self.assertRaises(SchedulingClientError) as raised: - SchedulingClient("https://scheduling.example.invalid/", request_timeout_seconds=-1.0) + SchedulingClient("https://scheduling.example.invalid/", attempt_timeout_seconds=-1.0) self.assertEqual(raised.exception.kind, "configuration") def test_timeouts_and_response_bound_accept_numbers_in_range(self) -> None: for keyword, value in ( - ("request_timeout_seconds", 2), - ("request_timeout_seconds", 1.5), + ("attempt_timeout_seconds", 2), + ("attempt_timeout_seconds", 1.5), + ("attempt_timeout_seconds", 2**64 - 1), ("connect_timeout_seconds", 2), ("connect_timeout_seconds", 1.5), - ("max_response_bytes", 1500), + ("connect_timeout_seconds", 2**64 - 1), + ("maximum_response_bytes", 1500), ): with self.subTest(keyword=keyword, value=value): SchedulingClient("https://scheduling.example.invalid/", **{keyword: value}) def test_bool_or_out_of_range_bound_is_a_configuration_error(self) -> None: for keyword, value in ( - ("request_timeout_seconds", True), - ("request_timeout_seconds", False), - ("request_timeout_seconds", -1), - ("request_timeout_seconds", float("nan")), - ("request_timeout_seconds", float("inf")), - ("request_timeout_seconds", "2"), + ("attempt_timeout_seconds", True), + ("attempt_timeout_seconds", False), + ("attempt_timeout_seconds", -1), + ("attempt_timeout_seconds", 2**64), + ("attempt_timeout_seconds", float("nan")), + ("attempt_timeout_seconds", float("inf")), + ("attempt_timeout_seconds", "2"), ("connect_timeout_seconds", True), ("connect_timeout_seconds", -1), - ("max_response_bytes", True), - ("max_response_bytes", False), - ("max_response_bytes", -1), - ("max_response_bytes", 2**64), - ("max_response_bytes", 1.5), - ("max_response_bytes", "1500"), + ("connect_timeout_seconds", 2**64), + ("maximum_response_bytes", True), + ("maximum_response_bytes", False), + ("maximum_response_bytes", -1), + ("maximum_response_bytes", 2**64), + ("maximum_response_bytes", 1.5), + ("maximum_response_bytes", "1500"), ): with self.subTest(keyword=keyword, value=value): with self.assertRaises(SchedulingClientError) as raised: @@ -73,6 +79,33 @@ def test_bool_or_out_of_range_bound_is_a_configuration_error(self) -> None: self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) + def test_none_means_omission_for_optional_configuration(self) -> None: + client = SchedulingClient( + "https://scheduling.example.invalid/", + attempt_timeout_seconds=None, + connect_timeout_seconds=None, + maximum_response_bytes=None, + user_agent=None, + trusted_root_certificates=None, + maximum_mutation_retries=None, + ) + self.assertIsInstance(client, SchedulingClient) + + def test_bool_is_not_a_page_limit(self) -> None: + client = SchedulingClient("https://scheduling.example.invalid/") + for call in ( + lambda: client.availability("valid-token", "offering", limit=True), + lambda: client.list_appointments( + "valid-token", + {"product": "casework", "recordType": "review-task", "identifier": "case:1"}, + limit=False, + ), + ): + with self.assertRaises(SchedulingClientError) as raised: + call() + self.assertEqual(raised.exception.kind, "invalid-request") + self.assertIs(raised.exception.outcome_unknown, False) + def test_token_error_does_not_repeat_credential(self) -> None: malformed = "bad token with spaces canary" client = SchedulingClient("https://scheduling.example.invalid/") diff --git a/crates/registry-scheduling-client-py/tests/python/test_mutation_retry.py b/crates/registry-scheduling-client-py/tests/python/test_mutation_retry.py index 4767e8c076..4b7e1d6a77 100644 --- a/crates/registry-scheduling-client-py/tests/python/test_mutation_retry.py +++ b/crates/registry-scheduling-client-py/tests/python/test_mutation_retry.py @@ -132,7 +132,7 @@ def test_a_503_is_resent_once_under_the_same_key_and_succeeds(self) -> None: def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> None: self.script(UNAVAILABLE, CREATED) - client = SchedulingClient(self.base_url, max_mutation_retries=0) + client = SchedulingClient(self.base_url, maximum_mutation_retries=0) with self.assertRaises(SchedulingClientError) as raised: client.create_hold("one-call-token", KEY, ADMISSION) @@ -145,7 +145,7 @@ def test_a_ceiling_of_zero_sends_once_and_reports_the_outcome_unknown(self) -> N def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> None: self.script(EXHAUSTED, CREATED) - client = SchedulingClient(self.base_url, max_mutation_retries=2) + client = SchedulingClient(self.base_url, maximum_mutation_retries=2) with self.assertRaises(SchedulingClientError) as raised: client.create_hold("one-call-token", KEY, ADMISSION) @@ -158,11 +158,11 @@ def test_a_4xx_refusal_is_never_resent_and_reports_the_outcome_known(self) -> No def test_a_ceiling_outside_zero_to_two_is_a_configuration_error(self) -> None: for retries in (0, 1, 2): with self.subTest(retries=retries): - SchedulingClient(self.base_url, max_mutation_retries=retries) + SchedulingClient(self.base_url, maximum_mutation_retries=retries) for retries in (3, 255, 256, -1, 1.5, "1", True): with self.subTest(retries=retries): with self.assertRaises(SchedulingClientError) as raised: - SchedulingClient(self.base_url, max_mutation_retries=retries) + SchedulingClient(self.base_url, maximum_mutation_retries=retries) self.assertEqual(raised.exception.kind, "configuration") self.assertIs(raised.exception.outcome_unknown, False) diff --git a/crates/registry-scheduling-client/README.md b/crates/registry-scheduling-client/README.md index b92dfddb60..b317a3ed9b 100644 --- a/crates/registry-scheduling-client/README.md +++ b/crates/registry-scheduling-client/README.md @@ -32,7 +32,7 @@ cancel) whose outcome is unknown, with the same key, headers, and body bytes, at most twice by default, waiting 250 ms and then 500 ms, or the service's `Retry-After` when that is longer and at most 5 seconds. A longer requested wait ends the retries. Configure the count with -`SchedulingClientConfig::with_max_mutation_retries` (0 to 2; 0 disables the +`SchedulingClientConfig::with_maximum_mutation_retries` (0 to 2; 0 disables the resend). Reads and the unkeyed hold release are never resent. Each attempt has the full request timeout, so a call can take up to three request timeouts plus the waits. diff --git a/crates/registry-scheduling-client/src/client.rs b/crates/registry-scheduling-client/src/client.rs index 8efda1f6f1..c8a45200af 100644 --- a/crates/registry-scheduling-client/src/client.rs +++ b/crates/registry-scheduling-client/src/client.rs @@ -46,8 +46,8 @@ const EXTERNAL_REFERENCE_IDENTIFIER_QUERY_PARAMETER: &str = "externalReferenceId pub struct SchedulingClient { http: reqwest::Client, base_url: ServiceBaseUrl, - max_response_bytes: u64, - max_mutation_retries: u8, + maximum_response_bytes: u64, + maximum_mutation_retries: u8, } impl fmt::Debug for SchedulingClient { @@ -55,8 +55,8 @@ impl fmt::Debug for SchedulingClient { formatter .debug_struct("SchedulingClient") .field("base_url", &"") - .field("max_response_bytes", &self.max_response_bytes) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_response_bytes", &self.maximum_response_bytes) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -65,7 +65,7 @@ impl SchedulingClient { pub fn new(config: SchedulingClientConfig) -> Result { let base_url = config.validate()?; let http = build_client(OutboundOptions { - request_timeout: config.request_timeout, + request_timeout: config.attempt_timeout, connect_timeout: config.connect_timeout, user_agent: config.user_agent.as_deref(), trusted_root_certificates: config.trusted_root_certificates.as_deref(), @@ -74,8 +74,8 @@ impl SchedulingClient { Ok(Self { http, base_url, - max_response_bytes: config.max_response_bytes, - max_mutation_retries: config.max_mutation_retries, + maximum_response_bytes: config.maximum_response_bytes, + maximum_mutation_retries: config.maximum_mutation_retries, }) } @@ -407,7 +407,7 @@ impl SchedulingClient { expected_status: StatusCode, ) -> Result, SchedulingClientError> { let request = &request; - retry_keyed_mutation(self.max_mutation_retries, || async move { + retry_keyed_mutation(self.maximum_mutation_retries, || async move { let Some(attempt) = request.try_clone() else { return KeyedMutationAttempt::Settled(Err(SchedulingClientError::invalid_request( "the request could not be prepared for sending", @@ -466,7 +466,7 @@ impl SchedulingClient { Some(trace_id), )); } - let body = read_bounded(response, self.max_response_bytes) + let body = read_bounded(response, self.maximum_response_bytes) .await .map_err(|error| SchedulingClientError::Transport { kind: read_failure_kind(&error), diff --git a/crates/registry-scheduling-client/src/config.rs b/crates/registry-scheduling-client/src/config.rs index e1c728079b..7911825f66 100644 --- a/crates/registry-scheduling-client/src/config.rs +++ b/crates/registry-scheduling-client/src/config.rs @@ -16,12 +16,12 @@ const MAXIMUM_RESPONSE_BYTES: u64 = 16 * 1024 * 1024; #[derive(Clone)] pub struct SchedulingClientConfig { pub(crate) base_url: Url, - pub(crate) request_timeout: Duration, + pub(crate) attempt_timeout: Duration, pub(crate) connect_timeout: Duration, - pub(crate) max_response_bytes: u64, + pub(crate) maximum_response_bytes: u64, pub(crate) user_agent: Option, pub(crate) trusted_root_certificates: Option>, - pub(crate) max_mutation_retries: u8, + pub(crate) maximum_mutation_retries: u8, } impl SchedulingClientConfig { @@ -29,18 +29,18 @@ impl SchedulingClientConfig { pub fn new(base_url: Url) -> Self { Self { base_url, - request_timeout: DEFAULT_REQUEST_TIMEOUT, + attempt_timeout: DEFAULT_REQUEST_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, - max_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, + maximum_response_bytes: DEFAULT_MAXIMUM_RESPONSE_BYTES, user_agent: None, trusted_root_certificates: None, - max_mutation_retries: DEFAULT_MUTATION_RETRIES, + maximum_mutation_retries: DEFAULT_MUTATION_RETRIES, } } #[must_use] - pub fn with_request_timeout(mut self, value: Duration) -> Self { - self.request_timeout = value; + pub fn with_attempt_timeout(mut self, value: Duration) -> Self { + self.attempt_timeout = value; self } @@ -51,8 +51,8 @@ impl SchedulingClientConfig { } #[must_use] - pub fn with_max_response_bytes(mut self, value: u64) -> Self { - self.max_response_bytes = value; + pub fn with_maximum_response_bytes(mut self, value: u64) -> Self { + self.maximum_response_bytes = value; self } @@ -73,8 +73,8 @@ impl SchedulingClientConfig { /// returned. Defaults to 2, accepts at most 2, and 0 disables the /// resend. #[must_use] - pub fn with_max_mutation_retries(mut self, value: u8) -> Self { - self.max_mutation_retries = value; + pub fn with_maximum_mutation_retries(mut self, value: u8) -> Self { + self.maximum_mutation_retries = value; self } @@ -82,12 +82,13 @@ impl SchedulingClientConfig { let base_url = ServiceBaseUrl::new(self.base_url.clone()).map_err(|_| { SchedulingClientError::configuration("the service base URL is not usable") })?; - if self.request_timeout.is_zero() || self.connect_timeout.is_zero() { + if self.attempt_timeout.is_zero() || self.connect_timeout.is_zero() { return Err(SchedulingClientError::configuration( "client timeouts must be greater than zero", )); } - if self.max_response_bytes == 0 || self.max_response_bytes > MAXIMUM_RESPONSE_BYTES { + if self.maximum_response_bytes == 0 || self.maximum_response_bytes > MAXIMUM_RESPONSE_BYTES + { return Err(SchedulingClientError::configuration( "the response body bound is outside the accepted range", )); @@ -101,7 +102,7 @@ impl SchedulingClientConfig { "the trusted root certificate bundle exceeds the accepted bound", )); } - if self.max_mutation_retries > MAXIMUM_MUTATION_RETRIES { + if self.maximum_mutation_retries > MAXIMUM_MUTATION_RETRIES { return Err(SchedulingClientError::configuration( "the mutation retry count exceeds the accepted bound", )); @@ -115,15 +116,15 @@ impl fmt::Debug for SchedulingClientConfig { formatter .debug_struct("SchedulingClientConfig") .field("base_url", &"") - .field("request_timeout", &self.request_timeout) + .field("attempt_timeout", &self.attempt_timeout) .field("connect_timeout", &self.connect_timeout) - .field("max_response_bytes", &self.max_response_bytes) + .field("maximum_response_bytes", &self.maximum_response_bytes) .field("user_agent_present", &self.user_agent.is_some()) .field( "trusted_root_certificates_present", &self.trusted_root_certificates.is_some(), ) - .field("max_mutation_retries", &self.max_mutation_retries) + .field("maximum_mutation_retries", &self.maximum_mutation_retries) .finish_non_exhaustive() } } @@ -149,32 +150,41 @@ mod tests { #[test] fn configuration_bounds_are_enforced_before_any_client_exists() { let url = Url::parse("https://scheduling.example.invalid/").expect("fixture URL"); + assert_eq!( + SchedulingClientConfig::new(url.clone()).maximum_response_bytes, + DEFAULT_MAXIMUM_RESPONSE_BYTES + ); let zero_timeout = - SchedulingClientConfig::new(url.clone()).with_request_timeout(Duration::ZERO); + SchedulingClientConfig::new(url.clone()).with_attempt_timeout(Duration::ZERO); assert!(matches!( zero_timeout.validate(), Err(SchedulingClientError::Configuration { .. }) )); - let zero_body_bound = SchedulingClientConfig::new(url.clone()).with_max_response_bytes(0); + let zero_body_bound = + SchedulingClientConfig::new(url.clone()).with_maximum_response_bytes(0); assert!(matches!( zero_body_bound.validate(), Err(SchedulingClientError::Configuration { .. }) )); let oversized_body_bound = SchedulingClientConfig::new(url.clone()) - .with_max_response_bytes(MAXIMUM_RESPONSE_BYTES + 1); + .with_maximum_response_bytes(MAXIMUM_RESPONSE_BYTES + 1); assert!(matches!( oversized_body_bound.validate(), Err(SchedulingClientError::Configuration { .. }) )); + assert!(SchedulingClientConfig::new(url.clone()) + .with_maximum_response_bytes(MAXIMUM_RESPONSE_BYTES) + .validate() + .is_ok()); let excessive_retries = SchedulingClientConfig::new(url.clone()) - .with_max_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1); + .with_maximum_mutation_retries(MAXIMUM_MUTATION_RETRIES + 1); assert!(matches!( excessive_retries.validate(), Err(SchedulingClientError::Configuration { .. }) )); for retries in 0..=MAXIMUM_MUTATION_RETRIES { assert!(SchedulingClientConfig::new(url.clone()) - .with_max_mutation_retries(retries) + .with_maximum_mutation_retries(retries) .validate() .is_ok()); } diff --git a/crates/registry-scheduling-client/src/lib.rs b/crates/registry-scheduling-client/src/lib.rs index 84b4fb7179..b52381bdb9 100644 --- a/crates/registry-scheduling-client/src/lib.rs +++ b/crates/registry-scheduling-client/src/lib.rs @@ -18,7 +18,7 @@ //! the client never generates one. A keyed command whose outcome is unknown //! (a timeout or broken exchange after the request was sent, or a 5xx answer) //! is resent, byte for byte and under the same key, at most -//! `SchedulingClientConfig::with_max_mutation_retries` times (2 by default, 0 +//! `SchedulingClientConfig::with_maximum_mutation_retries` times (2 by default, 0 //! disables it). Reads and the unkeyed hold release are never resent. A 5xx //! answer may follow a commit: when `SchedulingClientError::is_outcome_unknown` //! is still true, the safe recovery is the same request under the same key, diff --git a/crates/registry-scheduling-client/tests/http_boundary.rs b/crates/registry-scheduling-client/tests/http_boundary.rs index 2ed8aa1418..9c85d4d003 100644 --- a/crates/registry-scheduling-client/tests/http_boundary.rs +++ b/crates/registry-scheduling-client/tests/http_boundary.rs @@ -861,7 +861,7 @@ async fn oversized_answers_stay_bounded() { SchedulingClientConfig::new( Url::parse(&format!("http://{address}/")).expect("fixture URL"), ) - .with_max_response_bytes(64), + .with_maximum_response_bytes(64), ) .expect("client"); match bounded.list_resources(auth(&token), None).await { diff --git a/crates/registry-scheduling-client/tests/mutation_retry.rs b/crates/registry-scheduling-client/tests/mutation_retry.rs index a0b0ba1829..acfd5800bd 100644 --- a/crates/registry-scheduling-client/tests/mutation_retry.rs +++ b/crates/registry-scheduling-client/tests/mutation_retry.rs @@ -199,7 +199,7 @@ async fn a_timed_out_hold_is_resent_identically_under_the_same_key() { ]) .await; let token = token(); - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let complete = client .create_hold(SchedulingAuth::new(&token), "hold-7", &admission()) @@ -387,7 +387,7 @@ async fn the_retry_count_is_honored() { let token = token(); let mut config = config(&address); if let Some(retries) = retries { - config = config.with_max_mutation_retries(retries); + config = config.with_maximum_mutation_retries(retries); } let error = client(config) .create_hold(SchedulingAuth::new(&token), "hold-7", &admission()) @@ -419,8 +419,8 @@ async fn zero_retries_disables_the_resend() { let token = token(); let error = client( config(&address) - .with_request_timeout(Duration::from_millis(500)) - .with_max_mutation_retries(0), + .with_attempt_timeout(Duration::from_millis(500)) + .with_maximum_mutation_retries(0), ) .create_hold(SchedulingAuth::new(&token), "hold-7", &admission()) .await @@ -556,7 +556,7 @@ async fn an_unreadable_4xx_answer_is_unknown_but_never_resent() { (Unfinished::Close, TransportKind::Exchange), ] { let (address, requests, server) = serve_unfinished_conflict(ending).await; - let client = client(config(&address).with_request_timeout(Duration::from_millis(500))); + let client = client(config(&address).with_attempt_timeout(Duration::from_millis(500))); let token = token(); let error = client .create_hold(SchedulingAuth::new(&token), "hold-7", &admission()) diff --git a/crates/registry-scheduling/src/http.rs b/crates/registry-scheduling/src/http.rs index 06fd0e246b..2816d3c4a1 100644 --- a/crates/registry-scheduling/src/http.rs +++ b/crates/registry-scheduling/src/http.rs @@ -524,6 +524,7 @@ async fn appointment_history( } #[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] struct ListingQuery { #[serde(default)] cursor: Option, @@ -544,6 +545,7 @@ struct AppointmentListingQuery { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct AvailabilityQuery { offering: String, #[serde(default)] @@ -866,9 +868,17 @@ mod tests { async fn identified(Path(_): Path) -> StatusCode { StatusCode::NO_CONTENT } + async fn listing(Query(_): Query) -> StatusCode { + StatusCode::OK + } + async fn availability_query(Query(_): Query) -> StatusCode { + StatusCode::OK + } http_edge( Router::new() .route("/v1/echo", post(echo)) + .route("/v1/listing", get(listing)) + .route("/v1/availability-query", get(availability_query)) .route(HOLD_ROUTE, delete(identified)) .route(APPOINTMENT_ROUTE, get(identified)), ) @@ -946,6 +956,31 @@ mod tests { assert_eq!(body["code"], "request.unsupported-media-type"); } + #[tokio::test] + async fn listing_and_availability_queries_refuse_unknown_fields() { + for uri in [ + "/v1/listing?limti=10", + "/v1/availability-query?offering=general&strat=2026-10-23T00%3A00%3A00Z", + ] { + let response = send(edge_router(), traced_request("GET", uri)).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{uri}"); + let body = body_json(response).await; + assert_eq!(body["code"], "request.invalid", "{uri}"); + assert_eq!(body["status"], 400, "{uri}"); + } + } + + #[tokio::test] + async fn listing_and_availability_queries_keep_their_declared_fields() { + for uri in [ + "/v1/listing?cursor=opaque&limit=10", + "/v1/availability-query?offering=general&start=2026-10-23T00%3A00%3A00Z&end=2026-10-24T00%3A00%3A00Z&cursor=opaque&limit=10", + ] { + let response = send(edge_router(), traced_request("GET", uri)).await; + assert_eq!(response.status(), StatusCode::OK, "{uri}"); + } + } + #[tokio::test] async fn a_path_segment_that_is_not_an_identifier_is_a_request_problem() { // The two operations whose only caller-controlled input is the path diff --git a/crates/registry-scheduling/src/store.rs b/crates/registry-scheduling/src/store.rs index 5cb7730e09..7183f69901 100644 --- a/crates/registry-scheduling/src/store.rs +++ b/crates/registry-scheduling/src/store.rs @@ -79,6 +79,10 @@ const SCHEMA_VERSIONS: [i64; 1] = [100]; /// The 1.0 baseline schema version. A ledger holding any version below it /// was left by a pre-1.0 release, which this release refuses by name. const BASELINE_SCHEMA_VERSION: i64 = 100; +/// The most undelivered intents one operator query returns. This matches the +/// product's 200-item maximum for public listings, so an operator read does +/// not create a second, unbounded listing policy. +pub const MAXIMUM_UNDELIVERED_INTENTS_LIMIT: i64 = 200; const _: () = assert!( versions_start_at_baseline_and_increase(&SCHEMA_VERSIONS), "Scheduling migrations must start at BASELINE_SCHEMA_VERSION and strictly increase" @@ -149,6 +153,8 @@ pub enum StoreError { UnsupportedPostgres, #[error("the Scheduling database configuration is invalid")] Configuration, + #[error("the undelivered-intents limit must be between 1 and 200")] + InvalidUndeliveredIntentsLimit, #[error("the Scheduling database secret could not be resolved: {0}")] SecretConfiguration(String), #[error("the Scheduling database is not in the state this runtime expects")] @@ -2231,6 +2237,7 @@ impl PostgresStore { &self, limit: i64, ) -> Result, StoreError> { + let limit = validated_undelivered_intents_limit(limit)?; let client = self.client().await?; let rows = client .query( @@ -3927,6 +3934,14 @@ impl CapacityStatements for deadpool_postgres::Transaction<'_> { } } +fn validated_undelivered_intents_limit(limit: i64) -> Result { + if (1..=MAXIMUM_UNDELIVERED_INTENTS_LIMIT).contains(&limit) { + Ok(limit) + } else { + Err(StoreError::InvalidUndeliveredIntentsLimit) + } +} + fn tls_connector( config: &DatabaseConfig, secrets: &SecretResolver, @@ -3969,3 +3984,23 @@ fn tls_connector( .map_err(|_| StoreError::Configuration) } } + +#[cfg(test)] +mod query_bound_tests { + use super::*; + + #[test] + fn undelivered_intents_limit_is_positive_and_bounded() { + assert_eq!(validated_undelivered_intents_limit(1).unwrap(), 1); + assert_eq!( + validated_undelivered_intents_limit(MAXIMUM_UNDELIVERED_INTENTS_LIMIT).unwrap(), + MAXIMUM_UNDELIVERED_INTENTS_LIMIT + ); + for limit in [0, -1, MAXIMUM_UNDELIVERED_INTENTS_LIMIT + 1, i64::MAX] { + assert!(matches!( + validated_undelivered_intents_limit(limit), + Err(StoreError::InvalidUndeliveredIntentsLimit) + )); + } + } +} diff --git a/crates/registry-schedulingctl/src/lib.rs b/crates/registry-schedulingctl/src/lib.rs index 2e390381be..2a78d95bff 100644 --- a/crates/registry-schedulingctl/src/lib.rs +++ b/crates/registry-schedulingctl/src/lib.rs @@ -16,6 +16,7 @@ pub mod intents; mod project; pub mod records; mod report; +mod selected_binary; mod templates; use anyhow::Result; @@ -31,7 +32,7 @@ use serde_json::{json, Value}; use std::ffi::{OsStr, OsString}; use std::io; use std::path::PathBuf; -use std::process::ExitCode; +use std::process::{Command as ProcessCommand, ExitCode}; #[derive(Debug, Parser)] #[command( @@ -50,6 +51,8 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { + /// Run the production Scheduling service with the selected runtime. + Serve(ServeArgs), /// Create a complete Registry Scheduling authoring project in a new directory. Init(InitArgs), /// Check a project and, with --runtime-config, a runtime file, offline, and @@ -73,6 +76,17 @@ enum Command { Intents(IntentsArgs), } +#[derive(Debug, Args)] +struct ServeArgs { + /// Runtime configuration file passed unchanged to the Scheduling runtime. + #[arg(long, value_name = "ABSOLUTE_FILE")] + runtime_config: PathBuf, + + /// Scheduling runtime executable for an unmanaged installation. + #[arg(long, hide = true, env = "SCHEDULING_BIN", value_name = "BINARY")] + scheduling_bin: Option, +} + #[derive(Debug, Args)] struct InitArgs { /// New directory for the authored scheduling project. @@ -172,8 +186,12 @@ struct IntentsArgs { /// Runtime configuration file. #[arg(long = "runtime-config", value_name = "FILE")] config: PathBuf, - /// Most intents to list. - #[arg(long, default_value_t = 50)] + /// Most intents to list, from 1 through 200. + #[arg( + long, + default_value_t = 50, + value_parser = clap::value_parser!(i64).range(1..=registry_scheduling::store::MAXIMUM_UNDELIVERED_INTENTS_LIMIT) + )] limit: i64, } @@ -204,6 +222,9 @@ where T: Into, { let args = args.into_iter().map(Into::into).collect::>(); + let format_requested = args + .iter() + .any(|arg| arg == OsStr::new("--format") || arg.to_string_lossy().starts_with("--format=")); let machine_mode = args .windows(2) .any(|pair| pair[0] == OsStr::new("--format") && pair[1] == OsStr::new("json")) @@ -261,16 +282,22 @@ where return ExitCode::from(USAGE_EXIT); } } - let format = cli.format; - let command = command_path(&cli.command); - match run(cli) { + let Cli { format, command } = cli; + let command = match command { + Command::Serve(args) => { + return serve(args, format, format_requested, stdout, stderr); + } + command => command, + }; + let command_name = command_path(&command); + match run(Cli { format, command }) { Ok(mut report) => { // The exit code already tells the caller whether this refused; // the JSON must agree instead of reporting "ok": true underneath // a nonzero exit. let refusal = report_is_refusal(&report); if format == OutputFormat::Json { - report = completed_report(report, command, refusal); + report = completed_report(report, command_name, refusal); } else if refusal { report["ok"] = json!(false); } @@ -287,8 +314,15 @@ where Err(error) => { if let Some(configuration) = configuration_report(&error) { let exit = configuration_exit(&error); - if write_configuration_report(configuration, format, command, exit, stdout, stderr) - .is_err() + if write_configuration_report( + configuration, + format, + command_name, + exit, + stdout, + stderr, + ) + .is_err() { return output_failed(stderr); } @@ -296,7 +330,7 @@ where } let (exit, diagnostic) = classify_failure(&error); if write_failure( - &report::failure(command, exit, vec![diagnostic]), + &report::failure(command_name, exit, vec![diagnostic]), format, stdout, stderr, @@ -313,6 +347,7 @@ where /// The subcommand path a parsed invocation selected, as its report names it. fn command_path(command: &Command) -> &'static str { match command { + Command::Serve(_) => "serve", Command::Init(_) => "init", Command::Check(_) => "check", Command::Test(_) => "test", @@ -330,6 +365,7 @@ fn command_path(command: &Command) -> &'static str { fn run(cli: Cli) -> Result { match cli.command { + Command::Serve(_) => unreachable!("serve replaces the ctl process before report dispatch"), Command::Init(args) => project::init(&args.project, &args.template), Command::Check(args) => { let checked = project::check(&args.project, args.deny_warnings); @@ -364,6 +400,100 @@ fn run(cli: Cli) -> Result { } } +fn serve( + args: ServeArgs, + format: OutputFormat, + format_requested: bool, + stdout: &mut dyn io::Write, + stderr: &mut dyn io::Write, +) -> ExitCode { + if format_requested { + let message = + "--format does not apply to serve because service output keeps the Scheduling runtime stream" + .to_owned(); + if format == OutputFormat::Json { + if write_failure( + &usage_failure(message, "Remove --format and retry."), + OutputFormat::Json, + stdout, + stderr, + ) + .is_err() + { + return output_failed(stderr); + } + } else { + let _ = writeln!( + stderr, + "error: {message}\n next: Remove --format and retry." + ); + } + return ExitCode::from(USAGE_EXIT); + } + + let binary = match selected_binary::resolve_managed( + selected_binary::SCHEDULING, + args.scheduling_bin.as_deref(), + ) { + Ok(Some(binary)) => binary, + Ok(None) => args + .scheduling_bin + .unwrap_or_else(|| PathBuf::from("scheduling")), + Err(error) => { + let _ = writeln!( + stderr, + "schedulingctl serve could not select scheduling: {error:#}" + ); + return ExitCode::from(OPERATIONAL_FAILURE_EXIT); + } + }; + let mut command = ProcessCommand::new(binary); + command + .arg("--runtime-config") + .arg(args.runtime_config) + .arg("serve"); + replace_with_service(command, "scheduling", stderr) +} + +#[cfg(unix)] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn io::Write, +) -> ExitCode { + use std::os::unix::process::CommandExt as _; + + let error = command.exec(); + let _ = writeln!( + stderr, + "schedulingctl serve could not start {name}: {error}" + ); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) +} + +#[cfg(not(unix))] +fn replace_with_service( + mut command: ProcessCommand, + name: &str, + stderr: &mut dyn io::Write, +) -> ExitCode { + match command.status() { + Ok(status) => ExitCode::from( + status + .code() + .and_then(|code| u8::try_from(code).ok()) + .unwrap_or(OPERATIONAL_FAILURE_EXIT), + ), + Err(error) => { + let _ = writeln!( + stderr, + "schedulingctl serve could not start {name}: {error}" + ); + ExitCode::from(OPERATIONAL_FAILURE_EXIT) + } + } +} + /// A completed report may still describe a refusal: a fixture run with /// failing cases, which always fails the build. An activation plan that /// names a refusal apply would raise is a refusal too, except that the @@ -1415,6 +1545,21 @@ mod tests { }; assert_eq!(args.limit, 10); + for limit in ["0", "-1", "201", "9223372036854775808"] { + assert!( + Cli::try_parse_from([ + "schedulingctl", + "intents", + "--runtime-config", + "/runtime.yaml", + "--limit", + limit, + ]) + .is_err(), + "{limit} must be refused before the database is opened" + ); + } + assert!(Cli::try_parse_from(["schedulingctl", "intents"]).is_err()); assert!(Cli::try_parse_from([ diff --git a/crates/registry-schedulingctl/src/selected_binary.rs b/crates/registry-schedulingctl/src/selected_binary.rs new file mode 100644 index 0000000000..6bd3327e40 --- /dev/null +++ b/crates/registry-schedulingctl/src/selected_binary.rs @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{bail, Context, Result}; +use std::fs; +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +#[derive(Clone, Copy)] +pub(crate) struct HelperSpec { + pub(crate) binary: &'static str, + pub(crate) component: &'static str, + pub(crate) explicit_source: &'static str, + pub(crate) managed_env: &'static str, +} + +pub(crate) const SCHEDULING: HelperSpec = HelperSpec { + binary: "scheduling", + component: "scheduling", + explicit_source: "--scheduling-bin or SCHEDULING_BIN", + managed_env: "RSTACK_SCHEDULING_BIN", +}; + +pub(crate) fn resolve_managed( + spec: HelperSpec, + explicit: Option<&Path>, +) -> Result> { + resolve_managed_with_environment( + spec, + explicit, + std::env::var_os("RSTACK_GENERATION_DIR").as_deref(), + std::env::var_os(spec.managed_env).as_deref(), + std::env::var_os("RSTACK_RELEASE_TAG").as_deref(), + ) +} + +fn resolve_managed_with_environment( + spec: HelperSpec, + explicit: Option<&Path>, + generation_dir: Option<&OsStr>, + managed_path: Option<&OsStr>, + release_tag: Option<&OsStr>, +) -> Result> { + let Some(generation_dir) = generation_dir else { + return Ok(None); + }; + let release = release_tag + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|| "selected release".to_owned()); + let generation = fs::canonicalize(Path::new(generation_dir)).with_context(|| { + format!( + "rstack {release} selected generation {} cannot be resolved", + Path::new(generation_dir).display() + ) + })?; + let layout_path = generation + .join("components") + .join(spec.component) + .join(spec.binary) + .join(spec.binary); + let Some(managed_path) = managed_path else { + return missing_component(spec, &release); + }; + let selected = + canonical_helper(Path::new(managed_path)).or_else(|_| missing_component(spec, &release))?; + let layout = canonical_helper(&layout_path).or_else(|_| missing_component(spec, &release))?; + if selected != layout { + bail!( + "rstack {release} provided {} outside the selected generation helper path at {}; run `rstack install {}` to repair the selected generation", + spec.managed_env, + layout_path.display(), + spec.component + ); + } + if let Some(override_path) = explicit { + let override_identity = canonical_helper(override_path).with_context(|| { + format!( + "cannot resolve {} from {}", + spec.binary, spec.explicit_source + ) + })?; + if override_identity != selected { + bail!( + "{} points to {} but rstack {release} requires the {} helper from its selected generation at {}; a matching version string does not establish selected generation identity", + spec.explicit_source, + override_path.display(), + spec.binary, + selected.display() + ); + } + } + Ok(Some(selected)) +} + +fn canonical_helper(path: &Path) -> Result { + if !path.is_file() { + bail!("helper is not an installed file at {}", path.display()); + } + Ok(fs::canonicalize(path)?) +} + +fn missing_component(spec: HelperSpec, release: &str) -> Result { + bail!( + "rstack {release} selected generation has no usable {} helper; run `rstack install {}` and retry, schedulingctl will not install components automatically", + spec.binary, + spec.component + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fixture(root: &Path) -> (PathBuf, PathBuf) { + let generation = root.join("generation"); + let selected = generation.join("components/scheduling/scheduling/scheduling"); + fs::create_dir_all(selected.parent().unwrap()).unwrap(); + fs::write(&selected, b"same version\n").unwrap(); + (generation, selected) + } + + #[test] + fn managed_resolution_uses_the_selected_generation_runtime() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path()); + let resolved = resolve_managed_with_environment( + SCHEDULING, + None, + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap(); + assert_eq!(resolved, Some(fs::canonicalize(selected).unwrap())); + } + + #[test] + fn managed_resolution_refuses_a_same_version_runtime_with_wrong_identity() { + let root = tempfile::tempdir().unwrap(); + let (generation, selected) = fixture(root.path()); + let override_path = root.path().join("other-scheduling"); + fs::write(&override_path, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + SCHEDULING, + Some(&override_path), + Some(generation.as_os_str()), + Some(selected.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("SCHEDULING_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + assert!(diagnostic.contains("v-test"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_refuses_a_runtime_environment_outside_the_generation() { + let root = tempfile::tempdir().unwrap(); + let (generation, _) = fixture(root.path()); + let outside = root.path().join("outside-scheduling"); + fs::write(&outside, b"same version\n").unwrap(); + let error = resolve_managed_with_environment( + SCHEDULING, + None, + Some(generation.as_os_str()), + Some(outside.as_os_str()), + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!(diagnostic.contains("RSTACK_SCHEDULING_BIN"), "{diagnostic}"); + assert!(diagnostic.contains("selected generation"), "{diagnostic}"); + } + + #[test] + fn managed_resolution_names_the_install_command_when_component_is_missing() { + let root = tempfile::tempdir().unwrap(); + let generation = root.path().join("generation"); + fs::create_dir(&generation).unwrap(); + let error = resolve_managed_with_environment( + SCHEDULING, + None, + Some(generation.as_os_str()), + None, + Some(OsStr::new("v-test")), + ) + .unwrap_err(); + let diagnostic = format!("{error:#}"); + assert!( + diagnostic.contains("rstack install scheduling"), + "{diagnostic}" + ); + assert!(diagnostic.contains("will not install"), "{diagnostic}"); + } + + #[test] + fn unmanaged_resolution_preserves_existing_lookup() { + assert_eq!( + resolve_managed_with_environment(SCHEDULING, None, None, None, None).unwrap(), + None + ); + } +} diff --git a/crates/registry-schedulingctl/tests/intents_postgres.rs b/crates/registry-schedulingctl/tests/intents_postgres.rs index 708727519c..fe81fff691 100644 --- a/crates/registry-schedulingctl/tests/intents_postgres.rs +++ b/crates/registry-schedulingctl/tests/intents_postgres.rs @@ -13,7 +13,7 @@ use chrono::{DateTime, Duration, SubsecRound, Utc}; use registry_platform_config::{SecretProvider, SecretResolver}; use registry_scheduling::config::RuntimeConfig; -use registry_scheduling::store::PostgresStore; +use registry_scheduling::store::{PostgresStore, StoreError}; use serde_json::{json, Value}; use std::path::Path; use uuid::Uuid; @@ -346,4 +346,16 @@ async fn intents_lists_local_and_failed_oldest_due_first_and_respects_limit() { .batch_execute(&format!("DROP SCHEMA {schema} CASCADE")) .await .expect("the disposable schema is dropped"); + + // The store owns the same bound as the CLI. These values are refused + // before PostgreSQL is queried, which the already-dropped schema proves. + for limit in [0, -1, 201, i64::MAX] { + assert!( + matches!( + store.undelivered_intents(limit).await, + Err(StoreError::InvalidUndeliveredIntentsLimit) + ), + "{limit} must be refused before SQL" + ); + } } diff --git a/crates/registry-schedulingctl/tests/serve.rs b/crates/registry-schedulingctl/tests/serve.rs new file mode 100644 index 0000000000..0d58680293 --- /dev/null +++ b/crates/registry-schedulingctl/tests/serve.rs @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; + +fn runtime(path: &Path, name: &str, exit: u8) -> PathBuf { + let binary = path.join(name); + fs::write( + &binary, + format!( + "#!/usr/bin/env bash\nset -euo pipefail\nprintf 'runtime-stdout\\n'\nprintf 'runtime-stderr\\n' >&2\nprintf '%s\\n' \"$$\" \"$@\" > \"$RUNTIME_CAPTURE\"\nif [[ -n \"${{RUNTIME_LEASE_FD:-}}\" ]]; then printf 'lease-inherited\\n' >&\"$RUNTIME_LEASE_FD\"; fi\nexit {exit}\n" + ), + ) + .unwrap(); + fs::set_permissions(&binary, fs::Permissions::from_mode(0o700)).unwrap(); + binary +} + +fn run(binary: &Path, capture: &Path, arguments: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_schedulingctl")) + .args(arguments) + .env("SCHEDULING_BIN", binary) + .env("RUNTIME_CAPTURE", capture) + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("schedulingctl starts") +} + +#[test] +fn unmanaged_serve_preserves_runtime_arguments_streams_process_status_and_lease() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "scheduling", 17); + let capture = temp.path().join("capture"); + let lease = temp.path().join("lease"); + let mut launcher = Command::new("/bin/bash"); + launcher + .args([ + "-c", + "exec 9>\"$RUNTIME_LEASE_CAPTURE\"; export RUNTIME_LEASE_FD=9; export DYLD_FALLBACK_LIBRARY_PATH=\"${RESTORE_DYLD_FALLBACK_LIBRARY_PATH:-}\"; export DYLD_LIBRARY_PATH=\"${RESTORE_DYLD_LIBRARY_PATH:-}\"; export LD_LIBRARY_PATH=\"${RESTORE_LD_LIBRARY_PATH:-}\"; exec \"$CTL_BIN\" \"$@\"", + "schedulingctl-launcher", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ]) + .env("CTL_BIN", env!("CARGO_BIN_EXE_schedulingctl")) + .env("SCHEDULING_BIN", &binary) + .env("RUNTIME_CAPTURE", &capture) + .env("RUNTIME_LEASE_CAPTURE", &lease) + .env_remove("RSTACK_GENERATION_DIR") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + for (source, backup) in [ + ( + "DYLD_FALLBACK_LIBRARY_PATH", + "RESTORE_DYLD_FALLBACK_LIBRARY_PATH", + ), + ("DYLD_LIBRARY_PATH", "RESTORE_DYLD_LIBRARY_PATH"), + ("LD_LIBRARY_PATH", "RESTORE_LD_LIBRARY_PATH"), + ] { + if let Some(value) = std::env::var_os(source) { + launcher.env(backup, value); + } + } + let child = launcher.spawn().expect("schedulingctl starts"); + let expected_pid = child.id().to_string(); + let output = child.wait_with_output().unwrap(); + + assert_eq!( + output.status.code(), + Some(17), + "status={:?}, stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(output.stdout, b"runtime-stdout\n"); + assert_eq!(output.stderr, b"runtime-stderr\n"); + assert_eq!(fs::read_to_string(lease).unwrap(), "lease-inherited\n"); + assert_eq!( + fs::read_to_string(capture).unwrap(), + format!("{expected_pid}\n--runtime-config\n/tmp/runtime.yaml\nserve\n") + ); +} + +#[test] +fn unmanaged_serve_falls_back_to_path_without_a_binary_override() { + let temp = tempfile::tempdir().unwrap(); + runtime(temp.path(), "scheduling", 21); + let capture = temp.path().join("capture"); + let path = std::env::join_paths(std::iter::once(temp.path().to_owned()).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_schedulingctl")) + .args(["serve", "--runtime-config", "/tmp/path.yaml"]) + .env("PATH", path) + .env("RUNTIME_CAPTURE", &capture) + .env_remove("SCHEDULING_BIN") + .env_remove("RSTACK_GENERATION_DIR") + .output() + .expect("schedulingctl starts"); + + assert_eq!(output.status.code(), Some(21)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/path.yaml", "serve"] + ); +} + +#[test] +fn managed_serve_uses_the_selected_generation_instead_of_path() { + let temp = tempfile::tempdir().unwrap(); + let generation = temp.path().join("generation"); + let selected_directory = generation.join("components/scheduling/scheduling"); + fs::create_dir_all(&selected_directory).unwrap(); + let selected = runtime(&selected_directory, "scheduling", 19); + let poisoned_directory = temp.path().join("poisoned"); + fs::create_dir(&poisoned_directory).unwrap(); + runtime(&poisoned_directory, "scheduling", 97); + let capture = temp.path().join("capture"); + let poisoned_path = std::env::join_paths(std::iter::once(poisoned_directory).chain( + std::env::split_paths(&std::env::var_os("PATH").expect("PATH is set")), + )) + .unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_schedulingctl")) + .args(["serve", "--runtime-config", "/tmp/managed.yaml"]) + .env("PATH", poisoned_path) + .env("RUNTIME_CAPTURE", &capture) + .env("RSTACK_GENERATION_DIR", &generation) + .env("RSTACK_SCHEDULING_BIN", &selected) + .env("RSTACK_RELEASE_TAG", "v-test") + .env_remove("SCHEDULING_BIN") + .output() + .expect("schedulingctl starts"); + + assert_eq!(output.status.code(), Some(19)); + assert_eq!( + fs::read_to_string(capture) + .unwrap() + .lines() + .skip(1) + .collect::>(), + ["--runtime-config", "/tmp/managed.yaml", "serve"] + ); +} + +#[test] +fn serve_rejects_json_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "scheduling", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "--format", + "json", + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stderr.is_empty()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(report["ok"], false); + assert_eq!(report["command"], "usage"); + assert!(!capture.exists(), "the runtime must not start"); +} + +#[test] +fn serve_rejects_explicit_human_report_format_before_starting_the_runtime() { + let temp = tempfile::tempdir().unwrap(); + let binary = runtime(temp.path(), "scheduling", 0); + let capture = temp.path().join("capture"); + let output = run( + &binary, + &capture, + &[ + "serve", + "--runtime-config", + "/tmp/runtime.yaml", + "--format", + "human", + ], + ); + + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8_lossy(&output.stderr).contains("--format does not apply")); + assert!(!capture.exists(), "the runtime must not start"); +} diff --git a/crates/registry-stack-client-node/README.md b/crates/registry-stack-client-node/README.md index 4c1f47254d..4b9ff30adf 100644 --- a/crates/registry-stack-client-node/README.md +++ b/crates/registry-stack-client-node/README.md @@ -90,7 +90,7 @@ for the full rules. A keyed BReg, Casework, Messaging, or Scheduling mutation whose outcome is unknown is resent identically under the same idempotency key up to -`maxMutationRetries` times, a client option: 0 to 2, default 2, and 0 sends it +`maximumMutationRetries` times, a client option: 0 to 2, default 2, and 0 sends it once. Casework review-request create and cancel are excluded from this retry. Reads, unkeyed calls, and every 4xx refusal are never resent. When the returned error still reports `outcomeUnknown`, the mutation may have taken effect: recover by diff --git a/crates/registry-stack-client-node/__test__/facade.test-d.ts b/crates/registry-stack-client-node/__test__/facade.test-d.ts index 352951dec6..c42273197f 100644 --- a/crates/registry-stack-client-node/__test__/facade.test-d.ts +++ b/crates/registry-stack-client-node/__test__/facade.test-d.ts @@ -1,16 +1,42 @@ import { breg, casework, discovery, evidence, messaging, scheduling } from '..' const bregClient = new breg.BaseRegistryClient({ baseUrl: 'https://registry.example.invalid/' }) -const discoveryClient = new discovery.DiscoveryClient({ baseUrl: 'https://discovery.example.invalid/' }) +const discoveryClient = new discovery.DiscoveryClient({ + baseUrl: 'https://discovery.example.invalid/', + maximumResponseBytes: 1500, + trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----', +}) const evidenceClient = new evidence.EvidenceClient({ baseUrl: 'https://evidence.example.invalid/', trustedJwks: { keys: [] }, revokedKeyIds: [], token: { static: 'placeholder-token' }, }) -const caseworkClient = new casework.CaseworkClient({ baseUrl: 'https://casework.example.invalid/' }) -const messagingClient = new messaging.MessagingClient({ baseUrl: 'https://messaging.example.invalid/' }) -const schedulingClient = new scheduling.SchedulingClient({ baseUrl: 'https://scheduling.example.invalid/' }) +const caseworkClient = new casework.CaseworkClient({ + baseUrl: 'https://casework.example.invalid/', maximumResponseBytes: 1500, +}) +const messagingClient = new messaging.MessagingClient({ + baseUrl: 'https://messaging.example.invalid/', + attemptTimeoutMilliseconds: 5_000, + maximumResponseBytes: 1500, + maximumMutationRetries: 1, + trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----', +}) +const schedulingClient = new scheduling.SchedulingClient({ + baseUrl: 'https://scheduling.example.invalid/', + attemptTimeoutMilliseconds: 5_000, + maximumResponseBytes: 1500, + maximumMutationRetries: 1, + trustedRootCertificates: '-----BEGIN CERTIFICATE-----\nplaceholder\n-----END CERTIFICATE-----', +}) +// @ts-expect-error the public response-bound name is maximumResponseBytes +new messaging.MessagingClient({ baseUrl: 'https://messaging.example.invalid/', maxResponseBytes: 1500 }) +// @ts-expect-error the public attempt-timeout name is attemptTimeoutMilliseconds +new messaging.MessagingClient({ baseUrl: 'https://messaging.example.invalid/', requestTimeoutMilliseconds: 5_000 }) +// @ts-expect-error the public retry name is maximumMutationRetries +new scheduling.SchedulingClient({ baseUrl: 'https://scheduling.example.invalid/', maxMutationRetries: 1 }) +// @ts-expect-error null is not an omission in JavaScript configuration +new scheduling.SchedulingClient({ baseUrl: 'https://scheduling.example.invalid/', userAgent: null }) bregClient.listRecords('people', { top: 25 }) void bregClient.registryContract('reader').then((metadata) => { diff --git a/crates/registry-stack-client-node/breg/client.d.ts b/crates/registry-stack-client-node/breg/client.d.ts index c31200f41d..f821e856b8 100644 --- a/crates/registry-stack-client-node/breg/client.d.ts +++ b/crates/registry-stack-client-node/breg/client.d.ts @@ -62,24 +62,24 @@ export interface PrivateKeyJwtConfig { * authentication). Defaults to the token endpoint URL. This is not the * `resource` of the token request. */ - audience?: string | null + audience?: string /** * The RFC 8707 resource indicator the token is requested for: the resource * server's registered identifier, not a URL to fetch. */ - resource?: string | null + resource?: string /** * The scopes requested for the token, sent as one space-delimited `scope` * parameter. A requested scope may narrow the client's registered * permission set; it can never widen it. */ - scopes?: ReadonlyArray | null - assertionLifetimeSeconds?: SafeInteger | null - refreshMarginSeconds?: SafeInteger | null - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + scopes?: ReadonlyArray + assertionLifetimeSeconds?: SafeInteger + refreshMarginSeconds?: SafeInteger + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + userAgent?: string + trustedRootCertificates?: string } /** OAuth credentials are returned to the caller. Keep them out of logs and persistence. */ @@ -98,7 +98,7 @@ export interface ExchangeContext { audience: string generation: string deadlineSeconds: SafeInteger - grantId?: string | null + grantId?: string } export type ExchangePrivateKeyJwtConfig = PrivateKeyJwtConfig & { @@ -118,10 +118,10 @@ export interface RemoteExchangeSource { bootstrap: ExchangePrivateKeyJwtConfig bootstrapResource: string bootstrapScope: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + userAgent?: string + trustedRootCertificates?: string } export type ExchangeAuthorizationConfig = @@ -135,12 +135,12 @@ export type BaseRegistryAuthorization = export interface BaseRegistryClientConfig { baseUrl: string - authorization?: BaseRegistryAuthorization | null - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + authorization?: BaseRegistryAuthorization + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + trustedRootCertificates?: string /** * How many times a keyed mutation (create, patch, attachment upload or * delete, lifecycle action, immediate action, batch, tombstone, statistics @@ -149,7 +149,7 @@ export interface BaseRegistryClientConfig { * number from 0 to 2, default 2; 0 sends it once. Any other value is a * `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export type RecordFormat = 'json' | 'json-ld' diff --git a/crates/registry-stack-client-node/breg/index.d.ts b/crates/registry-stack-client-node/breg/index.d.ts index ac262118b6..cd2ae7fcf4 100644 --- a/crates/registry-stack-client-node/breg/index.d.ts +++ b/crates/registry-stack-client-node/breg/index.d.ts @@ -58,7 +58,7 @@ export declare class BaseRegistryClient { /** * Invoke one metadata-selected immediate action. An invocation whose * outcome is unknown is resent identically under the same key, at most - * `maxMutationRetries` times. + * `maximumMutationRetries` times. */ invokeAction(binding: BRegActionBinding, inputs: any, idempotencyKey: string, conditions?: BRegActionTargetConditions | undefined | null): Promise /** @@ -75,7 +75,7 @@ export declare class BaseRegistryClient { /** * Explicitly send a recovered invocation under its original key. An * invocation whose outcome is unknown is resent identically under that - * key, at most `maxMutationRetries` times. + * key, at most `maximumMutationRetries` times. */ executeRecoveredAction(binding: BRegActionBinding, recovered: BRegRecoveredAction): Promise /** Execute a recovered proposal start with its exact original request and key. */ @@ -91,7 +91,7 @@ export declare class BaseRegistryClient { /** * Execute one metadata-selected atomic batch. A batch whose outcome is * unknown is resent identically under the same key, at most - * `maxMutationRetries` times. + * `maximumMutationRetries` times. */ batchRecords(binding: BRegBatchBinding, request: any, idempotencyKey: string): Promise /** diff --git a/crates/registry-stack-client-node/casework/client.d.ts b/crates/registry-stack-client-node/casework/client.d.ts index e5607c9ee1..4f2babc96c 100644 --- a/crates/registry-stack-client-node/casework/client.d.ts +++ b/crates/registry-stack-client-node/casework/client.d.ts @@ -8,11 +8,11 @@ export type JsonObject = { readonly [key: string]: JsonValue } export interface CaseworkClientConfig { baseUrl: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + trustedRootCertificates?: string /** * How many times an idempotency-keyed mutation whose outcome is unknown is * resent, identically and under the same key, before its error is @@ -20,7 +20,7 @@ export interface CaseworkClientConfig { * unkeyed operations, and review request create and cancel are never * resent. Any other value is a `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export type PageStatus = 'complete' | 'budget-exhausted' | 'source-unavailable' diff --git a/crates/registry-stack-client-node/casework/client.js b/crates/registry-stack-client-node/casework/client.js index b71d650f5b..c9bca29727 100644 --- a/crates/registry-stack-client-node/casework/client.js +++ b/crates/registry-stack-client-node/casework/client.js @@ -85,12 +85,50 @@ function sanitize(value, kind) { return cloneJson(value, { nodes: 0, bytes: 0, active: new WeakSet() }, 0, kind); } +function configurationValue(config) { + if (!config || typeof config !== 'object' || Array.isArray(config) || isProxy(config)) { + throw normalized({}, 'configuration'); + } + const prototype = Object.getPrototypeOf(config); + if (prototype !== Object.prototype && prototype !== null) { + throw normalized({}, 'configuration'); + } + const allowed = new Set([ + 'baseUrl', + 'attemptTimeoutMilliseconds', + 'connectTimeoutMilliseconds', + 'maximumResponseBytes', + 'userAgent', + 'trustedRootCertificates', + 'maximumMutationRetries', + ]); + const copy = Object.create(null); + for (const key of Reflect.ownKeys(config)) { + if (typeof key !== 'string' || !allowed.has(key)) throw normalized({}, 'configuration'); + const descriptor = Object.getOwnPropertyDescriptor(config, key); + if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) { + throw normalized({}, 'configuration'); + } + const value = descriptor.value; + if (key === 'baseUrl' || key === 'userAgent' || key === 'trustedRootCertificates') { + if (typeof value !== 'string') throw normalized({}, 'configuration'); + copy[key] = value; + } else { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { + throw normalized({}, 'configuration'); + } + copy[key] = value; + } + } + return copy; +} + const NativeCaseworkClient = native.CaseworkClient; class CaseworkClient { constructor(config) { try { - this.native = new NativeCaseworkClient(sanitize(config, 'configuration')); + this.native = new NativeCaseworkClient(configurationValue(config)); } catch (error) { throw normalized(error, 'configuration'); } diff --git a/crates/registry-stack-client-node/casework/index.d.ts b/crates/registry-stack-client-node/casework/index.d.ts index c49c417530..eed7f03e1c 100644 --- a/crates/registry-stack-client-node/casework/index.d.ts +++ b/crates/registry-stack-client-node/casework/index.d.ts @@ -55,16 +55,16 @@ export declare class CaseworkClient { export interface CaseworkClientConfig { baseUrl: string - requestTimeoutMilliseconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number - maxResponseBytes?: number + maximumResponseBytes?: number userAgent?: string trustedRootCertificates?: string /** * How many times a keyed mutation whose outcome is unknown is resent under * the same idempotency key: 0 to 2, default 2, and 0 disables the resend. */ - maxMutationRetries?: number + maximumMutationRetries?: number } export interface CaseworkOutcome { diff --git a/crates/registry-stack-client-node/discovery/client.d.ts b/crates/registry-stack-client-node/discovery/client.d.ts index 060f8fe36d..61418c562a 100644 --- a/crates/registry-stack-client-node/discovery/client.d.ts +++ b/crates/registry-stack-client-node/discovery/client.d.ts @@ -2,10 +2,10 @@ export type ServiceKind = 'evidence'; export interface DiscoveryClientOptions { baseUrl: string; - requestTimeoutMilliseconds?: number; + attemptTimeoutMilliseconds?: number; connectTimeoutMilliseconds?: number; maximumResponseBytes?: number; - trustedRootCertificates?: Buffer; + trustedRootCertificates?: string; } export interface EvidenceTypeResolveRequest { diff --git a/crates/registry-stack-client-node/discovery/client.js b/crates/registry-stack-client-node/discovery/client.js index 4e1f1b85d9..f3919c6242 100644 --- a/crates/registry-stack-client-node/discovery/client.js +++ b/crates/registry-stack-client-node/discovery/client.js @@ -158,7 +158,7 @@ function configurationValue(options) { } const allowed = new Set([ 'baseUrl', - 'requestTimeoutMilliseconds', + 'attemptTimeoutMilliseconds', 'connectTimeoutMilliseconds', 'maximumResponseBytes', 'trustedRootCertificates', @@ -172,10 +172,10 @@ function configurationValue(options) { } const value = descriptor.value; if (key === 'trustedRootCertificates') { - if (!Buffer.isBuffer(value) || value.length > 4 * 1024 * 1024) { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 4 * 1024 * 1024) { throw inputError('configuration'); } - copy[key] = Buffer.from(value); + copy[key] = value; } else if (key === 'baseUrl') { if (typeof value !== 'string') throw inputError('configuration'); copy[key] = value; diff --git a/crates/registry-stack-client-node/discovery/index.d.ts b/crates/registry-stack-client-node/discovery/index.d.ts index 11eca0df39..f462e79506 100644 --- a/crates/registry-stack-client-node/discovery/index.d.ts +++ b/crates/registry-stack-client-node/discovery/index.d.ts @@ -13,10 +13,10 @@ export declare class DiscoveryClient { export interface DiscoveryClientOptions { baseUrl: string - requestTimeoutMilliseconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number maximumResponseBytes?: number - trustedRootCertificates?: Buffer + trustedRootCertificates?: string } export declare function renewUnchangedSelection(previous: any, current: any): any diff --git a/crates/registry-stack-client-node/evidence/client.d.ts b/crates/registry-stack-client-node/evidence/client.d.ts index 65f7629b25..93de20ddd6 100644 --- a/crates/registry-stack-client-node/evidence/client.d.ts +++ b/crates/registry-stack-client-node/evidence/client.d.ts @@ -120,8 +120,8 @@ export interface PrivateKeyJwtConfig { scopes?: ReadonlyArray assertionLifetimeSeconds?: number refreshMarginSeconds?: number - requestTimeoutMs?: number - connectTimeoutMs?: number + attemptTimeoutMilliseconds?: number + connectTimeoutMilliseconds?: number userAgent?: string /** Additional trust roots for the token endpoint, as a PEM bundle. */ trustedRootCertificates?: string @@ -151,11 +151,8 @@ export interface EvidenceExchangeContext { * This is not a `PrivateKeyJwtConfig`, even though the two describe the same * kind of client: an exchange is pinned to one resource and scope set, so both * are required here, and the object is read by the shared exchange - * configuration parser rather than by the binding's own reader, so its timeout - * members are the full-word ones that parser accepts. State at most one of - * `requestTimeoutMilliseconds` and `requestTimeoutSeconds`, and at most one of - * `connectTimeoutMilliseconds` and `connectTimeoutSeconds`; naming both of a - * pair is refused. + * configuration parser rather than by the binding's own reader. Node exposes + * both timeout controls in milliseconds. */ export interface EvidenceExchangeClientConfig { tokenEndpoint: string @@ -180,10 +177,8 @@ export interface EvidenceExchangeClientConfig { scopes: ReadonlyArray assertionLifetimeSeconds?: number refreshMarginSeconds?: number - requestTimeoutMilliseconds?: number - requestTimeoutSeconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number - connectTimeoutSeconds?: number userAgent?: string /** Additional trust roots for the token endpoint, as a PEM bundle. */ trustedRootCertificates?: string @@ -195,10 +190,8 @@ export interface EvidenceExchangeRemoteSourceConfig { bootstrap: EvidenceExchangeClientConfig bootstrapResource: string bootstrapScope: string - requestTimeoutMilliseconds?: number - requestTimeoutSeconds?: number + attemptTimeoutMilliseconds?: number connectTimeoutMilliseconds?: number - connectTimeoutSeconds?: number userAgent?: string /** Additional trust roots for the authority, as a PEM bundle. */ trustedRootCertificates?: string @@ -220,12 +213,12 @@ export interface EvidenceClientConfig { /** Configure exactly one of `token` or `authorization`. */ token?: EvidenceTokenConfig authorization?: { exchange: EvidenceExchangeAuthorizationConfig } - requestTimeoutMs?: number - connectTimeoutMs?: number + attemptTimeoutMilliseconds?: number + connectTimeoutMilliseconds?: number userAgent?: string /** Additional trust roots for the deployment, as a PEM bundle. */ trustedRootCertificates?: string - maxResponseBytes?: number + maximumResponseBytes?: number maxMetadataBytes?: number } diff --git a/crates/registry-stack-client-node/evidence/index.d.ts b/crates/registry-stack-client-node/evidence/index.d.ts index 2f53bde6ae..d63367bd09 100644 --- a/crates/registry-stack-client-node/evidence/index.d.ts +++ b/crates/registry-stack-client-node/evidence/index.d.ts @@ -26,7 +26,7 @@ export declare class EvidenceClient { * mandatory trust inputs. A key set or revoked-key list the verifier could * never use is refused, exactly as the Rust configuration refuses it. * - * `maxResponseBytes` bounds the signed response `send` reads. + * `maximumResponseBytes` bounds the signed response `send` reads. * `maxMetadataBytes` bounds the documents `discover` and `fetchJwks` read, * which are neither signed nor verified, and is a separate decision. */ diff --git a/crates/registry-stack-client-node/messaging/client.d.ts b/crates/registry-stack-client-node/messaging/client.d.ts index de9aff0994..4d31e4d550 100644 --- a/crates/registry-stack-client-node/messaging/client.d.ts +++ b/crates/registry-stack-client-node/messaging/client.d.ts @@ -9,18 +9,19 @@ export type JsonValue = JsonScalar | ReadonlyArray | { readonly [key: export interface MessagingClientConfig { baseUrl: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + /** Additional trust roots for the deployment, as PEM text. */ + trustedRootCertificates?: string /** * How many times a submission whose outcome is unknown is resent, * identically and under the same idempotency key, before its error is * returned: a whole number from 0 to 2, default 2; 0 sends it once. Any * other value is a `configuration` error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } export type Channel = 'email' | 'sms' diff --git a/crates/registry-stack-client-node/scheduling/client.d.ts b/crates/registry-stack-client-node/scheduling/client.d.ts index 43557bf0ec..04ea7bfc94 100644 --- a/crates/registry-stack-client-node/scheduling/client.d.ts +++ b/crates/registry-stack-client-node/scheduling/client.d.ts @@ -10,11 +10,12 @@ export type JsonValue = JsonScalar | ReadonlyArray | { readonly [key: export interface SchedulingClientConfig { baseUrl: string - requestTimeoutMilliseconds?: SafeInteger | null - connectTimeoutMilliseconds?: SafeInteger | null - maxResponseBytes?: SafeInteger | null - userAgent?: string | null - trustedRootCertificates?: string | null + attemptTimeoutMilliseconds?: SafeInteger + connectTimeoutMilliseconds?: SafeInteger + maximumResponseBytes?: SafeInteger + userAgent?: string + /** Additional trust roots for the deployment, as PEM text. */ + trustedRootCertificates?: string /** * How many times a keyed command (hold, appointment create, reschedule, * cancel) whose outcome is unknown is resent, identically and under the @@ -22,7 +23,7 @@ export interface SchedulingClientConfig { * to 2, default 2; 0 sends it once. Any other value is a `configuration` * error. */ - maxMutationRetries?: SafeInteger | null + maximumMutationRetries?: SafeInteger } /** An opaque record link Scheduling retains with a hold or appointment. */ diff --git a/docs/site/scripts/generate-cli-reference.mjs b/docs/site/scripts/generate-cli-reference.mjs index 6faf772809..074984d00d 100644 --- a/docs/site/scripts/generate-cli-reference.mjs +++ b/docs/site/scripts/generate-cli-reference.mjs @@ -33,9 +33,11 @@ export const expectedBinaries = [ 'evidence', 'evidence-oid4vci', 'evidencectl', + 'manifest', 'messaging', 'messagingctl', 'registry-render', + 'rstack', 'scheduling', 'schedulingctl', ]; @@ -54,6 +56,7 @@ const hiddenCommands = new Set([ 'local-audit-last-operation', ]); const groups = [ + { title: 'Registry Stack local command', binaries: ['rstack'] }, { title: 'Base Registry Engine', binaries: ['breg', 'bregctl'] }, // A group with a product is published only by docsets carrying that product // (docs/site/src/lib/docset-products.mjs gates its command pages). @@ -68,6 +71,7 @@ const groups = [ { title: 'Registry Messaging', binaries: ['messaging', 'messagingctl'], product: 'registry-messaging' }, { title: 'Registry Evidence', binaries: ['evidence', 'evidencectl'] }, { title: 'Evidence credential delivery', binaries: ['evidence-oid4vci'] }, + { title: 'Registry Manifest', binaries: ['manifest'] }, { title: 'Registry Render', binaries: ['registry-render'] }, ]; diff --git a/docs/site/src/content/docs/changelog.mdx b/docs/site/src/content/docs/changelog.mdx index bc8ed63140..105ccbe1fe 100644 --- a/docs/site/src/content/docs/changelog.mdx +++ b/docs/site/src/content/docs/changelog.mdx @@ -732,7 +732,7 @@ relevant product pages on this site rather than duplicating release notes. [unknown outcomes and the same-key retry](../reference/client-api/#unknown-outcomes-and-the-same-key-retry). {/* Evidence: crates/registry-breg-client/src/error.rs, is_outcome_unknown; - crates/registry-breg-client/src/config.rs, with_max_mutation_retries; + crates/registry-breg-client/src/config.rs, with_maximum_mutation_retries; release/notes/v0.40.0.md, "Clients and recovery". */} - The unified Node.js and Python clients add Registry Scheduling: the `scheduling` namespace of diff --git a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx index 0a6f1ce56a..b4ef85ceb3 100644 --- a/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-scheduling.mdx @@ -50,11 +50,11 @@ or settles the command, while a new key can commit it twice. The Rust client in `is_outcome_unknown()` is true in those cases and false for a typed 4xx refusal, and it resends the identical request at most twice by default, waiting 250 ms and then 500 ms, or the runtime's `Retry-After` when that is longer. A `Retry-After` over 5 seconds ends the retries and returns -the answer. `with_max_mutation_retries` on its +the answer. `with_maximum_mutation_retries` on its configuration sets the count from 0, which sends once, to 2. It never resends a read or a release. {/* Evidence: crates/registry-scheduling-client/src/error.rs, is_outcome_unknown; - crates/registry-scheduling-client/src/config.rs, with_max_mutation_retries; + crates/registry-scheduling-client/src/config.rs, with_maximum_mutation_retries; crates/registry-scheduling-client/tests/mutation_retry.rs; crates/registry-platform-httputil/src/client/retry.rs, retry_keyed_mutation. */} diff --git a/docs/site/src/content/docs/reference/client-api.mdx b/docs/site/src/content/docs/reference/client-api.mdx index 62512666cc..1c13d5c615 100644 --- a/docs/site/src/content/docs/reference/client-api.mdx +++ b/docs/site/src/content/docs/reference/client-api.mdx @@ -210,9 +210,9 @@ the rest of that answer could not be read. Each attempt has the full request tim operations that carry no idempotency key, are never resent. In Rust, `is_outcome_unknown()` on the client error reports the classification, and -`with_max_mutation_retries` on the client configuration sets the count from 0, which sends once, to +`with_maximum_mutation_retries` on the client configuration sets the count from 0, which sends once, to 2. The Python and Node clients run the Rust client and apply the same rule. Python takes the count -as the `max_mutation_retries` constructor keyword and Node as the `maxMutationRetries` config member; +as the `maximum_mutation_retries` constructor keyword and Node as the `maximumMutationRetries` config member; a value outside 0 to 2 is a `configuration` error. The Python error's `outcome_unknown` attribute and the Node error's `outcomeUnknown` property report the classification as a boolean. @@ -242,11 +242,11 @@ the key is refused with `410`. crates/registry-scheduling-core/src/problem.rs, IdempotencyExpired and IdempotencyKeyReused; crates/registry-casework/src/http.rs, IdempotencyKeyReused; crates/registry-breg-client/src/error.rs, is_outcome_unknown; - crates/registry-breg-client/src/config.rs, with_max_mutation_retries; + crates/registry-breg-client/src/config.rs, with_maximum_mutation_retries; crates/registry-breg-client/tests/mutation_retry.rs; - crates/registry-breg-client-node/client.d.ts, maxMutationRetries, outcomeUnknown; + crates/registry-breg-client-node/client.d.ts, maximumMutationRetries, outcomeUnknown; crates/registry-breg-client-node/__test__/mutation-retry.test.js; - crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi, max_mutation_retries, + crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi, maximum_mutation_retries, outcome_unknown; crates/registry-breg-client-py/tests/python/test_mutation_retry.py */} @@ -257,7 +257,7 @@ the key is refused with `410`. crates/registry-messaging/src/messages.rs, replay(); crates/registry-messaging/src/retention.rs, delete_records(); crates/registry-messaging-client/tests/mutation_retry.rs; - crates/registry-messaging-client-node/client.d.ts, maxMutationRetries, outcomeUnknown; + crates/registry-messaging-client-node/client.d.ts, maximumMutationRetries, outcomeUnknown; crates/registry-messaging-client-py/tests/python/test_mutation_retry.py */} @@ -267,7 +267,7 @@ hold carries no key and is never resent. {/* Evidence: crates/registry-scheduling-client/src/error.rs, is_outcome_unknown; crates/registry-scheduling-client/tests/mutation_retry.rs; - crates/registry-scheduling-client-node/client.d.ts, maxMutationRetries, outcomeUnknown; + crates/registry-scheduling-client-node/client.d.ts, maximumMutationRetries, outcomeUnknown; crates/registry-scheduling-client-py/tests/python/test_mutation_retry.py */} In Casework, every keyed review-task and Directory mutation is resent, and the error's @@ -282,7 +282,7 @@ Casework answers every 503 with `Retry-After: 5`, so a resend after one waits 5 crates/registry-casework/src/http.rs, Retry-After on 503; crates/registry-casework/src/problem.rs, IdempotencyExpired; crates/registry-casework-client/tests/mutation_retry.rs; - crates/registry-casework-client-node/client.d.ts, maxMutationRetries, outcomeUnknown; + crates/registry-casework-client-node/client.d.ts, maximumMutationRetries, outcomeUnknown; crates/registry-casework-client-node/__test__/mutation-retry.test.js; crates/registry-casework-client-py/tests/python/test_mutation_retry.py */} @@ -308,7 +308,7 @@ from registry_client import discovery client = discovery.DiscoveryClient( base_url="https://discovery.example.invalid/", - request_timeout_seconds=30, + attempt_timeout_seconds=30, connect_timeout_seconds=10, maximum_response_bytes=8 * 1024 * 1024, ) @@ -319,7 +319,7 @@ const { discovery } = require('@registrystack/client'); const client = new discovery.DiscoveryClient({ baseUrl: 'https://discovery.example.invalid/', - requestTimeoutMilliseconds: 30_000, + attemptTimeoutMilliseconds: 30_000, connectTimeoutMilliseconds: 10_000, maximumResponseBytes: 8 * 1024 * 1024, }); @@ -328,7 +328,7 @@ const client = new discovery.DiscoveryClient({ Node also accepts the base URL as a bare string. The client sends no credentials: a Discovery index is public by construction, so there is no authorization option to set. -`DiscoveryClientConfig` provides `with_request_timeout`, `with_connect_timeout`, +`DiscoveryClientConfig` provides `with_attempt_timeout`, `with_connect_timeout`, `with_maximum_response_bytes`, and `with_trusted_root_certificates`. ### Discovery operations @@ -427,7 +427,7 @@ client = evidence.EvidenceClient( trusted_jwks=trusted_jwks, revoked_key_ids=[], token=token, - request_timeout_seconds=30, + attempt_timeout_seconds=30, connect_timeout_seconds=10, ) ``` @@ -440,12 +440,12 @@ const client = new evidence.EvidenceClient({ trustedJwks: trustedJwks, revokedKeyIds: [], token: { static: token }, - requestTimeoutMs: 30_000, - connectTimeoutMs: 10_000, + attemptTimeoutMilliseconds: 30_000, + connectTimeoutMilliseconds: 10_000, }); ``` -`maxResponseBytes` and `max_response_bytes` bound the signed response `send` reads. +`maximumResponseBytes` and `maximum_response_bytes` bound the signed response `send` reads. `maxMetadataBytes` and `max_metadata_bytes` bound the documents `discover` and `fetch_jwks` read, which are neither signed nor verified, and are a separate decision. `user_agent` and `userAgent` replace the default; `trusted_root_certificates` and `trustedRootCertificates` add trust roots for @@ -470,8 +470,8 @@ in both languages. The private-key-JWT configuration requires `token_endpoint`, `client_id`, and `client_key` (Node: `tokenEndpoint`, `clientId`, `clientKey`). Its optional members in Python are `audience`, `resource`, `scopes`, `assertion_lifetime_seconds`, `refresh_margin_seconds`, -`request_timeout_seconds`, `connect_timeout_seconds`, and `user_agent`. Node takes the same eight -in camel case, with `requestTimeoutMs` and `connectTimeoutMs` for the timeouts, and adds string-valued +`attempt_timeout_seconds`, `connect_timeout_seconds`, and `user_agent`. Node takes the same eight +in camel case, with `attemptTimeoutMilliseconds` and `connectTimeoutMilliseconds` for the timeouts, and adds string-valued `trustedRootCertificates` for the token endpoint. Python has no nested trust anchor for the token endpoint: its top-level `trusted_root_certificates` crosses as genuine `bytes` and the nested configuration crosses as JSON, which carries no byte values. `client_key` holds the private half; it @@ -648,9 +648,9 @@ from registry_client import breg client = breg.BaseRegistryClient( base_url="https://breg.example.invalid/institution-a", authorization={"static": token}, - request_timeout_seconds=30, + attempt_timeout_seconds=30, connect_timeout_seconds=10, - max_response_bytes=8 * 1024 * 1024, + maximum_response_bytes=8 * 1024 * 1024, ) ``` @@ -660,25 +660,25 @@ const { breg } = require('@registrystack/client'); const client = new breg.BaseRegistryClient({ baseUrl: 'https://breg.example.invalid/institution-a', authorization: { static: token }, - requestTimeoutMilliseconds: 30_000, + attemptTimeoutMilliseconds: 30_000, connectTimeoutMilliseconds: 10_000, - maxResponseBytes: 8 * 1024 * 1024, + maximumResponseBytes: 8 * 1024 * 1024, }); ``` The Rust facade does not re-export `Url`; add the `url` crate beside it. `BaseRegistryClientConfig` -provides `with_token_provider`, `with_request_timeout`, `with_connect_timeout`, -`with_max_response_bytes`, `with_user_agent`, `with_trusted_root_certificates`, and -`with_max_mutation_retries`. Node and Python take the same settings as constructor options. +provides `with_token_provider`, `with_attempt_timeout`, `with_connect_timeout`, +`with_maximum_response_bytes`, `with_user_agent`, `with_trusted_root_certificates`, and +`with_maximum_mutation_retries`. Node and Python take the same settings as constructor options. `authorization` is optional. Without it the client reaches only the health and readiness probes, which never send a bearer token; every other route refuses a request without one. An unsupported shape is refused at construction with kind `configuration`. {/* Evidence: crates/registry-stack-client/src/lib.rs, BaseRegistryClient, BaseRegistryClientConfig, - crates/registry-breg-client/src/config.rs, with_token_provider, with_max_mutation_retries, - crates/registry-breg-client-node/client.d.ts, requestTimeoutMilliseconds, - crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi, request_timeout_seconds */} + crates/registry-breg-client/src/config.rs, with_token_provider, with_maximum_mutation_retries, + crates/registry-breg-client-node/client.d.ts, attemptTimeoutMilliseconds, + crates/registry-breg-client-py/python/registry_breg_client/__init__.pyi, attempt_timeout_seconds */} ### Verify a webhook delivery @@ -1187,11 +1187,13 @@ const client = new messaging.MessagingClient({ }); ``` -`MessagingClientConfig` provides `with_request_timeout`, `with_connect_timeout`, -`with_max_response_bytes`, `with_user_agent`, and `with_trusted_root_certificates`. The Python -constructor takes the same settings as keyword arguments and the Node constructor as config members. -`with_max_mutation_retries`, the Python `max_mutation_retries` keyword, and the Node -`maxMutationRetries` member set the [same-key retry](#unknown-outcomes-and-the-same-key-retry) count +`MessagingClientConfig` provides `with_attempt_timeout`, `with_connect_timeout`, +`with_maximum_response_bytes`, `with_user_agent`, and `with_trusted_root_certificates`. The Python +constructor takes the same settings as snake case keyword arguments, including +`maximum_response_bytes`; the Node constructor takes camel case config members, including +`maximumResponseBytes`. +`with_maximum_mutation_retries`, the Python `maximum_mutation_retries` keyword, and the Node +`maximumMutationRetries` member set the [same-key retry](#unknown-outcomes-and-the-same-key-retry) count for `submit`. A response is bounded to 4 MiB by default and to 16 MiB at most. The configuration holds no credential, and its debug output redacts the base URL, the user agent, and any trusted root @@ -1372,10 +1374,11 @@ const client = new scheduling.SchedulingClient({ }); ``` -`SchedulingClientConfig` provides `with_request_timeout`, `with_connect_timeout`, -`with_max_response_bytes`, `with_user_agent`, `with_trusted_root_certificates`, and -`with_max_mutation_retries`. The Python constructor takes the same settings as keyword arguments and -the Node constructor as config members. +`SchedulingClientConfig` provides `with_attempt_timeout`, `with_connect_timeout`, +`with_maximum_response_bytes`, `with_user_agent`, `with_trusted_root_certificates`, and +`with_maximum_mutation_retries`. The Python constructor takes the same settings as snake case keyword +arguments, including `maximum_response_bytes`; the Node constructor takes camel case config +members, including `maximumResponseBytes`. {/* Evidence: crates/registry-scheduling-client/src/config.rs, SchedulingClientConfig; crates/registry-scheduling-client/src/client.rs, SchedulingClient; diff --git a/docs/site/src/data/cli-reference.yaml b/docs/site/src/data/cli-reference.yaml index 03500c7a54..7257b21f6d 100644 --- a/docs/site/src/data/cli-reference.yaml +++ b/docs/site/src/data/cli-reference.yaml @@ -8,4 +8,4 @@ # published index until human review. status: current last_reviewed: 2026-10-18 -unreviewed_binaries: [coordinator, coordinatorctl] +unreviewed_binaries: [coordinator, coordinatorctl, manifest, rstack] diff --git a/products/breg/CHANGELOG.md b/products/breg/CHANGELOG.md index 6b9a21d071..83bf19c0cf 100644 --- a/products/breg/CHANGELOG.md +++ b/products/breg/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Add the closed `logLevel` runtime setting to BReg, `breg-mcp`, and + `breg-review`. Each existing product environment variable takes precedence, + then the runtime file, then `info`; an unknown environment or configuration + value refuses startup instead of falling back. - Add optional `writableInputs` to Proposal grants. When present, Start refuses a value for any other Action input, and Edit, Revise and Rebase refuse a change to one, with `400 request.invalid` located at `/input/`. diff --git a/products/breg/README.md b/products/breg/README.md index c9059a8d1d..9eb93b7761 100644 --- a/products/breg/README.md +++ b/products/breg/README.md @@ -242,6 +242,11 @@ Runtime files set `apiVersion` to documents bounded defaults for operational tuning while keeping package, database, authority, role, and secret-reference fields explicit. +The top-level `logLevel` accepts `error`, `warn`, or `info` and defaults to +`info`. `BREG_LOG` overrides the authored value for process-local operation. +An unknown value in either source refuses startup; neither source accepts +filter directives or enables dependency logging. + ## Scope ### Record response contract diff --git a/products/breg/examples/mcp-runtime/runtime.yaml b/products/breg/examples/mcp-runtime/runtime.yaml index b5a6962d78..e434a56755 100644 --- a/products/breg/examples/mcp-runtime/runtime.yaml +++ b/products/breg/examples/mcp-runtime/runtime.yaml @@ -2,6 +2,7 @@ # offline with `breg-mcp --runtime-config runtime.yaml check`. apiVersion: id.registrystack.org/formats/breg/mcp-runtime/v1alpha1 kind: BRegMcpRuntimeConfig +logLevel: info listener: bind: 127.0.0.1:8110 tlsTermination: operator-controlled-upstream diff --git a/products/breg/examples/minimal/runtime.example.yaml b/products/breg/examples/minimal/runtime.example.yaml index 38f4e3eeb9..5805829a3b 100644 --- a/products/breg/examples/minimal/runtime.example.yaml +++ b/products/breg/examples/minimal/runtime.example.yaml @@ -8,6 +8,7 @@ # absolute paths below need not exist where it runs. apiVersion: id.registrystack.org/formats/breg/runtime/v1alpha1 kind: BRegRuntimeConfig +logLevel: info listener: bind: 127.0.0.1:8080 identity: diff --git a/products/breg/examples/review-runtime/runtime.yaml b/products/breg/examples/review-runtime/runtime.yaml index 8db3d5885c..19b1f22bb5 100644 --- a/products/breg/examples/review-runtime/runtime.yaml +++ b/products/breg/examples/review-runtime/runtime.yaml @@ -2,6 +2,7 @@ # it offline with `breg-review --runtime-config runtime.yaml check`. apiVersion: id.registrystack.org/formats/breg/review-runtime/v1alpha1 kind: BRegReviewRuntimeConfig +logLevel: info listener: bind: 127.0.0.1:8115 tlsTermination: operator-controlled-upstream diff --git a/products/breg/fixtures/generic-operator-workflows/runtime.example.yaml b/products/breg/fixtures/generic-operator-workflows/runtime.example.yaml index 387308c650..77f21b773a 100644 --- a/products/breg/fixtures/generic-operator-workflows/runtime.example.yaml +++ b/products/breg/fixtures/generic-operator-workflows/runtime.example.yaml @@ -8,6 +8,7 @@ # Every host here is under `.example.invalid`, which never resolves. apiVersion: id.registrystack.org/formats/breg/runtime/v1alpha1 kind: BRegRuntimeConfig +logLevel: info # Where the server listens. Client addresses and TLS termination belong to # whatever sits in front of it: the runtime reads neither peer addresses nor diff --git a/products/breg/generated/mcp-runtime/mcp-runtime.schema.json b/products/breg/generated/mcp-runtime/mcp-runtime.schema.json index 04eb44d3df..2a7f87e134 100644 --- a/products/breg/generated/mcp-runtime/mcp-runtime.schema.json +++ b/products/breg/generated/mcp-runtime/mcp-runtime.schema.json @@ -274,6 +274,15 @@ "pattern": "^[a-z][a-z0-9_-]{0,63}$", "type": "string" }, + "LogLevel": { + "description": "The operational log level. The vocabulary is closed so configuration\ncannot turn on debug output or dependency logging.", + "enum": [ + "error", + "warn", + "info" + ], + "type": "string" + }, "PrivateListenerConfig": { "additionalProperties": false, "description": "The listener of a runtime that declares its TLS termination and network\nexposure.", @@ -597,6 +606,11 @@ "listener": { "$ref": "#/$defs/PrivateListenerConfig" }, + "logLevel": { + "$ref": "#/$defs/LogLevel", + "default": "info", + "description": "Operational log level when `BREG_MCP_LOG` is not set. Defaults to\n`info`; accepted values are `error`, `warn`, and `info`." + }, "rateLimits": { "$ref": "#/$defs/RateLimitsConfig" }, diff --git a/products/breg/generated/review-runtime/review-runtime.schema.json b/products/breg/generated/review-runtime/review-runtime.schema.json index 097de386d5..f4768ad0f2 100644 --- a/products/breg/generated/review-runtime/review-runtime.schema.json +++ b/products/breg/generated/review-runtime/review-runtime.schema.json @@ -135,6 +135,15 @@ "pattern": "^[a-z][a-z0-9_-]{0,63}$", "type": "string" }, + "LogLevel": { + "description": "The operational log level. The vocabulary is closed so configuration\ncannot turn on debug output or dependency logging.", + "enum": [ + "error", + "warn", + "info" + ], + "type": "string" + }, "PrivateListenerConfig": { "additionalProperties": false, "description": "The listener of a runtime that declares its TLS termination and network\nexposure.", @@ -398,6 +407,11 @@ "listener": { "$ref": "#/$defs/PrivateListenerConfig" }, + "logLevel": { + "$ref": "#/$defs/LogLevel", + "default": "info", + "description": "Operational log level when `BREG_REVIEW_LOG` is not set. Defaults to\n`info`; accepted values are `error`, `warn`, and `info`." + }, "publicOrigin": { "$ref": "#/$defs/Url", "description": "The browser-facing origin, such as `https://review.example`: an https\nURL with no path, query, or fragment; plain http is accepted only on\na loopback host under `development-loopback`. The sign-in redirect\nURI is this origin with `/signin/callback`." diff --git a/products/breg/generated/runtime/runtime.schema.json b/products/breg/generated/runtime/runtime.schema.json index 68ea08967f..d13869b661 100644 --- a/products/breg/generated/runtime/runtime.schema.json +++ b/products/breg/generated/runtime/runtime.schema.json @@ -247,6 +247,15 @@ "pattern": "^[a-z][a-z0-9_-]{0,63}$", "type": "string" }, + "LogLevel": { + "description": "The operational log level selected by the runtime file when `BREG_LOG`\nis not set. The closed vocabulary prevents configuration from enabling\ndebug output or dependency logging.", + "enum": [ + "error", + "warn", + "info" + ], + "type": "string" + }, "OidcAlgorithm": { "enum": [ "EdDSA", @@ -1515,6 +1524,11 @@ "listener": { "$ref": "#/$defs/RawRegistryListener" }, + "logLevel": { + "$ref": "#/$defs/LogLevel", + "default": "info", + "description": "Operational log level when `BREG_LOG` is not set. Defaults to `info`;\naccepted values are `error`, `warn`, and `info`." + }, "metricsListener": { "$ref": "#/$defs/RawMetricsListenerConfig", "description": "Optional operator-private metrics listener. Absent by default, which\nserves no metrics surface at all." diff --git a/products/coordinator/SECURITY-REVIEW-NOTES.md b/products/coordinator/SECURITY-REVIEW-NOTES.md index 21fe4fe70a..a69c0fa3b5 100644 --- a/products/coordinator/SECURITY-REVIEW-NOTES.md +++ b/products/coordinator/SECURITY-REVIEW-NOTES.md @@ -5,6 +5,42 @@ material because commit messages do not survive a squash. Each section names the threat it answers, the defaults the product ships, where Rust enforces it, the tests that pin it, and the residual risk it accepts. +## Coordinatorctl access-token files + +### Threat + +An authenticated `coordinatorctl` command reads `--token-file` before it calls +the service. Opening a FIFO with an ordinary blocking read can hold the command +indefinitely, and following a symbolic link means the named path does not +identify the file the operator intended to use. + +### Enforcement and defaults + +On Unix, `coordinatorctl` opens the token path read-only with `NOFOLLOW`, +`NONBLOCK`, and `CLOEXEC`, then verifies from the opened descriptor that it is +a regular file before reading any bytes. Other platforms reject a symlink or +non-regular path before opening it and verify that the opened handle is a +regular file. The existing 16,384-byte bound, compact-token validation, and +value-free diagnostics remain unchanged. This check applies only to +`--token-file`; ordinary authored input files keep their existing contract. + +### Tests + +- `crates/registry-coordinator/tests/cli_remote.rs`: + `token_file_refuses_symlinks_and_fifos_without_blocking` bounds the child + process wait, exercises a symbolic link and FIFO, and proves neither output + stream contains the synthetic token. +- The same target's + `a_relative_token_file_is_read_from_the_current_directory` keeps the normal + regular-file path and relative-path behavior covered. + +### Accepted residuals + +- `coordinatorctl` remains local operator tooling on a trusted single-user + machine. The Unix open is race-safe for the final path component; broader + filesystem and directory-component races remain accepted for this adopter + CLI rather than introducing a directory-descriptor traversal policy. + ## OIDC issuer transport and private addresses ### Threat diff --git a/products/coordinator/generated/operations/README.md b/products/coordinator/generated/operations/README.md index c1e5c0ece7..1de9ca02a9 100644 --- a/products/coordinator/generated/operations/README.md +++ b/products/coordinator/generated/operations/README.md @@ -38,10 +38,10 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -102,10 +102,10 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -172,10 +172,10 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -234,10 +234,10 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -299,10 +299,10 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -372,10 +372,10 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -458,11 +458,11 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectionMayLowerLimits": true, + "maximumMutationRetries": 0, "maximumResponseBytes": 65536, - "mutationRetries": 0, - "redirects": "refused", - "requestTimeoutMilliseconds": 2000 + "redirects": "refused" }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -521,12 +521,12 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 8000, "connectionMayLowerLimits": true, + "maximumMutationRetries": 0, "maximumRequestBytes": 65536, "maximumResponseBytes": 65536, - "mutationRetries": 0, - "redirects": "refused", - "requestTimeoutMilliseconds": 8000 + "redirects": "refused" }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -606,10 +606,10 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -674,10 +674,10 @@ Host execution and completion contract: "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, diff --git a/products/coordinator/generated/operations/catalog.json b/products/coordinator/generated/operations/catalog.json index aa177e347a..2cf2f2fc6e 100644 --- a/products/coordinator/generated/operations/catalog.json +++ b/products/coordinator/generated/operations/catalog.json @@ -35,10 +35,10 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -143,10 +143,10 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -329,10 +329,10 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -658,10 +658,10 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -954,12 +954,12 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 8000, "connectionMayLowerLimits": true, + "maximumMutationRetries": 0, "maximumRequestBytes": 65536, "maximumResponseBytes": 65536, - "mutationRetries": 0, - "redirects": "refused", - "requestTimeoutMilliseconds": 8000 + "redirects": "refused" }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -1258,11 +1258,11 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectionMayLowerLimits": true, + "maximumMutationRetries": 0, "maximumResponseBytes": 65536, - "mutationRetries": 0, - "redirects": "refused", - "requestTimeoutMilliseconds": 2000 + "redirects": "refused" }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -1339,10 +1339,10 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -1609,10 +1609,10 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -1940,10 +1940,10 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, @@ -2148,10 +2148,10 @@ "limits": { "attemptTimeoutSeconds": 10, "http": { + "attemptTimeoutMilliseconds": 2000, "connectTimeoutMilliseconds": 1000, - "maximumResponseBytes": 65536, - "mutationRetries": 0, - "requestTimeoutMilliseconds": 2000 + "maximumMutationRetries": 0, + "maximumResponseBytes": 65536 }, "maximumCallAttempts": 10, "maximumCallRetryDelaySeconds": 300, diff --git a/products/manifest/CHANGELOG.md b/products/manifest/CHANGELOG.md index 54d0b1d3b4..50e8793335 100644 --- a/products/manifest/CHANGELOG.md +++ b/products/manifest/CHANGELOG.md @@ -7,6 +7,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +- BREAKING: `render` selects its artifact with `--artifact-format`; report + `--format` does not apply to artifact stdout. `publish` writes its bundle to + `--output` and can emit a separate human or JSON report. Replace `render + --format ` with `render --artifact-format `, `publish + --out ` with `publish --output `, and `--json` with + `--format json`. Retired spellings are refused before file reads or writes + and name their replacement. + ## [0.43.0] - 2026-10-21 - BREAKING: metadata display fields use the shared display-text rules, and diff --git a/products/manifest/README.md b/products/manifest/README.md index dacb2494de..db13f55b47 100644 --- a/products/manifest/README.md +++ b/products/manifest/README.md @@ -78,13 +78,13 @@ cargo run -p registry-manifest-cli --features schema --example manifest-schema - Render one artifact: ```sh -cargo run --locked -p registry-manifest-cli -- render profiles/example-civil-registration/fixtures/metadata.yaml --format bregdcat-ap +cargo run --locked -p registry-manifest-cli -- render profiles/example-civil-registration/fixtures/metadata.yaml --artifact-format bregdcat-ap ``` Publish a static metadata directory: ```sh -cargo run --locked -p registry-manifest-cli -- publish profiles/example-civil-registration/fixtures/metadata.yaml --out target/metadata/public +cargo run --locked -p registry-manifest-cli -- publish profiles/example-civil-registration/fixtures/metadata.yaml --output target/metadata/public ``` The generated bundle uses `/metadata/index.json` as its canonical metadata diff --git a/products/manifest/docs/reference.md b/products/manifest/docs/reference.md index 9e93d1b30e..e657efeb4f 100644 --- a/products/manifest/docs/reference.md +++ b/products/manifest/docs/reference.md @@ -35,7 +35,7 @@ instead, carrying `ok`, `command`, `status`, `filesChecked`, `errors`, `warnings Compiles the manifest and writes one renderer's output to stdout. ```text -render --format [--profile ] [--dataset ] [--entity ] [--form ] [--offering ] +render --artifact-format [--profile ] [--dataset ] [--entity ] [--form ] [--offering ] ``` ### `publish` @@ -43,11 +43,14 @@ render --format [--profile ] [--dataset ] [--en Compiles the manifest, runs all renderers, writes the full artifact tree to ``, and writes `index.json`. ```text -publish --out [--site-root ] +publish [--output ] [--site-root ] [--format human|json] ``` -`--site-root` is optional. When set, the two `.well-known/*` discovery documents are written -under `--site-root` instead of `--out`; every other artifact still writes under `--out`. See +`--output` defaults to `public/metadata`. `--site-root` is optional. When set, +the two `.well-known/*` discovery documents are written under `--site-root` +instead of `--output`; every other artifact still writes under `--output`. +The default human report and the JSON `ManifestCtlReport` describe the completed +publication separately from its artifact files. See [Publish output artifacts](#publish-output-artifacts). ### `validate-profiles` @@ -542,7 +545,7 @@ Source: [`crates/registry-manifest-cli/src/main.rs`](../../../crates/registry-manifest-cli/src/main.rs) (`publish_command`). -All paths are relative to the `--out` directory, except the two `.well-known/*` discovery +All paths are relative to the `--output` directory, except the two `.well-known/*` discovery documents, which are relative to `--site-root` when that flag is set. | Artifact path | Format | Source renderer | Version marker | @@ -582,7 +585,7 @@ Digest fields use `sha256:` values: - `artifacts[].sha256`: content digest for each published metadata artifact listed in `artifacts`. -The `artifacts` inventory uses paths relative to `--out`, records each artifact's media type, +The `artifacts` inventory uses paths relative to `--output`, records each artifact's media type, and excludes `index.json` because it contains the package digest. Discovery documents under `.well-known/` are also excluded because they may be written under a separate `--site-root` while still pointing at the same `/metadata/index.json` entry point. diff --git a/products/manifest/docs/release-notes.md b/products/manifest/docs/release-notes.md index 0db080227b..8eedc86202 100644 --- a/products/manifest/docs/release-notes.md +++ b/products/manifest/docs/release-notes.md @@ -2,6 +2,12 @@ ## Unreleased +- BREAKING: `render` uses `--artifact-format` because its standard output is + the exact artifact stream. `publish` uses `--output` and supports a separate + `--format human|json` report. Replace retired `render --format`, `publish + --out`, and `--json` invocations with the named new options; the CLI refuses + retired spellings before effects and names the replacement. + ## 0.43.0 - BREAKING: metadata display fields, profile names and descriptions, and diff --git a/products/manifest/docs/validate-and-render.md b/products/manifest/docs/validate-and-render.md index 695ff7bb79..2ccba09cd1 100644 --- a/products/manifest/docs/validate-and-render.md +++ b/products/manifest/docs/validate-and-render.md @@ -84,15 +84,16 @@ Validation checks include: ### 2. Render a single artifact -Use `render` to write one renderer's output to stdout. -The `--format` flag selects the renderer. +Use `render` to write one renderer's exact JSON output to stdout. +The `--artifact-format` flag selects the renderer. Report `--format` does not +apply to this artifact stream. Render the catalog JSON: ```sh cargo run --bin registry-manifest -- render \ products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ - --format catalog + --artifact-format catalog ``` Render base DCAT JSON-LD: @@ -100,7 +101,7 @@ Render base DCAT JSON-LD: ```sh cargo run --bin registry-manifest -- render \ products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ - --format dcat + --artifact-format dcat ``` Render a Core Public Service Vocabulary Application Profile (CPSV-AP) service catalogue: @@ -108,18 +109,19 @@ Render a Core Public Service Vocabulary Application Profile (CPSV-AP) service ca ```sh cargo run --bin registry-manifest -- render \ products/manifest/fixtures/cpsv-ap/health-linked-child-support.metadata.yaml \ - --format cpsv-ap + --artifact-format cpsv-ap ``` -To render the BRegDCAT-AP profile variant via `--format dcat`, pass `--profile bregdcat-ap`. -Alternatively, use the `--format bregdcat-ap` shorthand: +To render the BRegDCAT-AP profile variant via `--artifact-format dcat`, pass +`--profile bregdcat-ap`. Alternatively, use the `--artifact-format +bregdcat-ap` shorthand: Render BRegDCAT-AP JSON-LD: ```sh cargo run --bin registry-manifest -- render \ products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ - --format bregdcat-ap + --artifact-format bregdcat-ap ``` For optional public ITB/SEMIC validator smoke checks against rendered DCAT and @@ -131,7 +133,7 @@ Render SHACL (Shapes Constraint Language) node shapes: ```sh cargo run --bin registry-manifest -- render \ products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ - --format shacl + --artifact-format shacl ``` Render a JSON Schema Draft 2020-12 for a specific dataset and entity. @@ -205,7 +207,7 @@ Redirect to a file when you want to inspect or diff it: ```sh cargo run --bin registry-manifest -- render \ products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ - --format bregdcat-ap > /tmp/breg.jsonld + --artifact-format bregdcat-ap > /tmp/breg.jsonld ``` ### 3. Publish a static bundle @@ -217,7 +219,7 @@ digest. ```sh cargo run --bin registry-manifest -- publish \ products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ - --out target/metadata/public + --output target/metadata/public ``` The output directory will contain `catalog.json`, `dcat.jsonld`, `cpsv-ap.jsonld` when the @@ -230,14 +232,14 @@ Linked-data outputs include embedded SKOS-shaped codelist nodes when the manifes codelists. See [Registry Manifest reference](./reference.md) for the full artifact list. -By default, `.well-known/*` writes under `--out` alongside every other artifact. Pass +By default, `.well-known/*` writes under `--output` alongside every other artifact. Pass `--site-root ` to write the two well-known documents under a separate directory instead, useful when the metadata bundle is published as a sibling of a larger site root: ```sh cargo run --bin registry-manifest -- publish \ products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml \ - --out target/metadata/public \ + --output target/metadata/public \ --site-root target/site ``` diff --git a/products/manifest/examples/README.md b/products/manifest/examples/README.md index 2e5a3a6e1e..47c59da087 100644 --- a/products/manifest/examples/README.md +++ b/products/manifest/examples/README.md @@ -12,11 +12,11 @@ cargo run --locked -p registry-manifest-cli -- validate products/manifest/profil Render BRegDCAT-AP JSON-LD: ```sh -cargo run --locked -p registry-manifest-cli -- render products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --format bregdcat-ap +cargo run --locked -p registry-manifest-cli -- render products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --artifact-format bregdcat-ap ``` Publish a static metadata bundle: ```sh -cargo run --locked -p registry-manifest-cli -- publish products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --out target/metadata/public +cargo run --locked -p registry-manifest-cli -- publish products/manifest/profiles/example-civil-registration/fixtures/metadata.yaml --output target/metadata/public ``` diff --git a/products/manifest/scripts/check-contract-kernel.sh b/products/manifest/scripts/check-contract-kernel.sh index 443c891a87..7492c675e6 100755 --- a/products/manifest/scripts/check-contract-kernel.sh +++ b/products/manifest/scripts/check-contract-kernel.sh @@ -32,5 +32,5 @@ for manifest in "$@"; do fi name="$(slug "${manifest}")" run cargo run -p registry-manifest-cli -- validate "${resolved_manifest}" - run cargo run -p registry-manifest-cli -- publish "${resolved_manifest}" --out "${out_dir}/${name}" + run cargo run -p registry-manifest-cli -- publish "${resolved_manifest}" --output "${out_dir}/${name}" done diff --git a/products/manifest/scripts/itb-semic-smoke.sh b/products/manifest/scripts/itb-semic-smoke.sh index 88f761f0c4..3db13ddab9 100755 --- a/products/manifest/scripts/itb-semic-smoke.sh +++ b/products/manifest/scripts/itb-semic-smoke.sh @@ -98,7 +98,7 @@ echo "==> registry-manifest: validate ${manifest}" cargo run -p registry-manifest-cli -- validate "${manifest}" echo "==> registry-manifest: publish ${out_dir}" -cargo run -p registry-manifest-cli -- publish "${manifest}" --out "${out_dir}" +cargo run -p registry-manifest-cli -- publish "${manifest}" --output "${out_dir}" if [[ -z "${json_validator_url}" && "${run_remote}" != "1" ]]; then echo "remote ITB/SEMIC validators skipped; rerun with ITB_SEMIC_REMOTE=1" diff --git a/release/notes/v0.44.0.md b/release/notes/v0.44.0.md new file mode 100644 index 0000000000..1d2d6f4c15 --- /dev/null +++ b/release/notes/v0.44.0.md @@ -0,0 +1,70 @@ +# Registry Stack v0.44.0 + +This release introduces `rstack`, a single public command for a managed local +installation, and aligns parameter handling across the maintained command, +runtime, client, and authoring surfaces. It does not change the ownership of +service deployment, database migration, or governed records. + +## Local command and installation migration + +BREAKING for a managed local installation: only `rstack` is placed on `PATH`. +Use `rstack breg`, `rstack casework`, `rstack discovery`, `rstack evidence`, +`rstack manifest`, `rstack messaging`, `rstack render`, `rstack scheduling`, +and `rstack coordinator` in place of the corresponding product tool names. +Independently deployed service binaries and images retain their own +entrypoints. See the [rstack guide](../../crates/registry-rstack/README.md) +for the full command mapping and installation procedure. + +The release-pinned `rstack-v0.44.0-install.sh` installs the launcher alone by +default. Select named components or `--all` at bootstrap, then use +`rstack install`, `uninstall`, `components list`, `doctor`, and `cleanup` to +manage them. `rstack update --release vX.Y.Z --preview` reports an exact update +before activation. The updater refuses downgrades and retains the current +generation if target verification fails. Old invocations hold their generation +until they exit. No database or service rollback is implied. + +Authenticate published release material using [release verification](../VERIFY.md) +before relying on the installer's checksum checks. The component and launcher +assets must match the release manifest on the selected platform. An offline +`--asset-dir` can supply the same verified assets. + +## Parameter migration + +BREAKING: authored JavaScript and Node client configuration uses +`maximumResponseBytes`; Rust uses `with_maximum_response_bytes`, and Python +uses `maximum_response_bytes`. Replace the shorter `maxResponseBytes`, +`with_max_response_bytes`, and `max_response_bytes` spellings at the affected +call sites. The response bounds retain their existing byte units, defaults, +and ceilings. Node constructors reject unknown keys, `null` for non-nullable +options, unsafe or fractional integers, and binary input where PEM text is +required for `trustedRootCertificates`. Python `None` means omission only for +typed optional parameters. + +BREAKING: outbound client attempt and connection timeouts use +`attemptTimeoutMilliseconds` and `connectTimeoutMilliseconds` in Node, +`attempt_timeout_seconds` and `connect_timeout_seconds` in Python, and +`with_attempt_timeout` and `with_connect_timeout` in Rust. Nested token and +exchange configurations use the same attempt name. The same-key retry limit +is `maximumMutationRetries`, `maximum_mutation_retries`, or +`with_maximum_mutation_retries`; it counts additional sends. Existing timeout +budgets, retry defaults, and ceilings stay unchanged. Inbound listener request +timeouts remain a separate runtime setting. + +BREAKING: command report encoding uses `--format human|json`, with `human` the +default even in a pipe. Product artifact choices and output destinations have +their own flags. In particular, Manifest's `--output` is an exact destination; +unknown, duplicate, or surplus singleton options fail before writes. Update +scripts that relied on implicit JSON output, old Manifest artifact flags, or +permissive argument parsing. + +BReg runtime, BReg MCP, and BReg Review accept `logLevel` values `error`, +`warn`, or `info`. The environment takes precedence over authored +configuration, then `info` is used. Existing runtime defaults outside these +components are unchanged. HTTP inputs and configuration readers reject the +ambiguous or unknown values covered by their contracts, rather than silently +discarding or merging them. + +Run the owning product checks and inspect configuration diagnostics before +cutover. For downstream apps, use a supported client and tool version with +the new parameter spelling; companion repositories that consume these APIs +must be updated with this release. diff --git a/release/schemas/release-candidate-v2.schema.json b/release/schemas/release-candidate-v2.schema.json index 6a753559e6..1a43e680cb 100644 --- a/release/schemas/release-candidate-v2.schema.json +++ b/release/schemas/release-candidate-v2.schema.json @@ -125,6 +125,9 @@ } } }, + "components": { + "$ref": "#/$defs/componentManifest" + }, "images": { "type": "array", "minItems": 1, @@ -260,9 +263,93 @@ } } } + }, + { + "if": { + "properties": { + "release": { + "properties": { + "version": { + "anyOf": [ + { "pattern": "^0\\.(?:4[4-9]|[5-9][0-9])\\.[0-9]+$" }, + { "pattern": "^0\\.[1-9][0-9]{2,}\\.[0-9]+$" }, + { "pattern": "^[1-9][0-9]*\\.[0-9]+\\.[0-9]+$" } + ] + } + } + } + } + }, + "then": { + "required": ["components"] + }, + "else": { + "not": { + "required": ["components"] + } + } } ], "$defs": { + "componentManifest": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "release", "launcher", "components"], + "properties": { + "schema_version": { "const": "registry-stack.components.v1" }, + "release": { "type": "string", "pattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$" }, + "launcher": { + "type": "object", + "additionalProperties": false, + "required": ["linux-amd64", "linux-arm64", "macos-arm64"], + "properties": { + "linux-amd64": { "$ref": "#/$defs/componentAsset" }, + "linux-arm64": { "$ref": "#/$defs/componentAsset" }, + "macos-arm64": { "$ref": "#/$defs/componentAsset" } + } + }, + "components": { + "type": "object", + "propertyNames": { + "enum": ["breg", "casework", "discovery", "evidence", "manifest", "messaging", "render", "scheduling", "coordinator"] + }, + "additionalProperties": { "$ref": "#/$defs/component" } + } + } + }, + "component": { + "type": "object", + "additionalProperties": false, + "required": ["entrypoint", "dependencies", "platforms"], + "properties": { + "entrypoint": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "dependencies": { "type": "array", "uniqueItems": true, "items": { "type": "string" } }, + "platforms": { + "type": "object", + "minProperties": 1, + "propertyNames": { "enum": ["linux-amd64", "linux-arm64", "macos-arm64"] }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["assets", "capabilities"], + "properties": { + "assets": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/componentAsset" } }, + "capabilities": { "type": "object", "additionalProperties": { "type": "boolean" } } + } + } + } + } + }, + "componentAsset": { + "type": "object", + "additionalProperties": false, + "required": ["name", "size", "sha256"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "size": { "type": "integer", "minimum": 1 }, + "sha256": { "$ref": "#/$defs/sha256" } + } + }, "sha": { "type": "string", "pattern": "^[0-9a-f]{40}$" diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index 4d61414960..9db3b8e116 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -63,6 +63,11 @@ discoveryctl_in_release="$(python3 "${script_dir}/release_roster.py" \ if [[ "${discoveryctl_in_release}" == true ]]; then include_discoveryctl=1 fi +include_rstack=0 +if [[ "$(python3 "${script_dir}/release_roster.py" \ + rstack-in-release "${version}")" == true ]]; then + include_rstack=1 +fi include_breg=0 if ((version_major > 0 || version_minor >= 26)); then include_breg=1 @@ -234,6 +239,12 @@ build_payload() { prepare_zig_toolchain if [[ "${group}" == all || "${group}" == core ]]; then + if [[ "${include_rstack}" -eq 1 ]]; then + cargo build --release --locked -p registry-rstack --bin rstack + cp target/release/rstack "dist/bin/rstack-${RELEASE_TAG}-linux-amd64" + test "$("dist/bin/rstack-${RELEASE_TAG}-linux-amd64" --version)" = \ + "rstack ${RELEASE_TAG#v}" + fi cargo build --release --locked \ -p registry-manifest-cli cp target/release/registry-manifest "dist/bin/registry-manifest-${RELEASE_TAG}-linux-amd64" @@ -583,6 +594,9 @@ if [[ ("${group}" == all || "${group}" == messaging) && "${include_messaging}" - fi fi if [[ "${group}" == all || "${group}" == core ]]; then + if [[ "${include_rstack}" -eq 1 ]]; then + bin_assets+=("rstack-${tag}-linux-amd64") + fi bin_assets+=( "evidence-${tag}-linux-amd64" "evidencectl-${tag}-linux-amd64" diff --git a/release/scripts/build-release-native-platform.sh b/release/scripts/build-release-native-platform.sh index f951a9e0bf..b726ce6d65 100755 --- a/release/scripts/build-release-native-platform.sh +++ b/release/scripts/build-release-native-platform.sh @@ -124,6 +124,11 @@ include_schedulingctl=0 if ((version_major > 0 || version_minor >= 36)); then include_schedulingctl=1 fi +include_rstack=0 +if [[ "$(python3 "${script_dir}/release_roster.py" \ + rstack-in-release "${version}")" == true ]]; then + include_rstack=1 +fi output_parent="$(dirname -- "${output}")" output_name="$(basename -- "${output}")" @@ -185,6 +190,13 @@ stage() { } build_core() { + if [[ "${include_rstack}" -eq 1 ]]; then + "${cargo_bin}" build --release --locked \ + -p registry-rstack --bin rstack --target "${target}" + stage rstack "rstack-${tag}-${asset}" + test "$("${staged_executable}" --version)" = \ + "rstack ${display_version}" + fi "${cargo_bin}" build --release --locked \ -p registry-evidence -p registry-evidencectl \ -p registry-evidence-oid4vci \ @@ -293,6 +305,9 @@ rm -rf -- "${temporary}/smoke" assets=() if [[ "${group}" == core || "${group}" == all ]]; then + if [[ "${include_rstack}" -eq 1 ]]; then + assets+=("rstack-${tag}-${asset}") + fi assets+=( "evidence-${tag}-${asset}" "evidencectl-${tag}-${asset}" diff --git a/release/scripts/release_candidate.py b/release/scripts/release_candidate.py index 6cc5bae809..e7d783ebcd 100644 --- a/release/scripts/release_candidate.py +++ b/release/scripts/release_candidate.py @@ -23,6 +23,7 @@ sys.path.insert(0, str(SCRIPT_DIR)) import release_roster # noqa: E402 +import rstack_components # noqa: E402 V2_SCHEMA_VERSION = "registry-stack.release-candidate.v2" @@ -505,6 +506,14 @@ def _release_payload_inventory(version: str) -> dict[str, str]: if release_roster.coordinator_in_release(version_tuple): inventory[f"coordinator-{tag}-linux-amd64"] = "binary" inventory[f"coordinatorctl-{tag}-linux-amd64"] = "binary" + if ( + release_roster.RSTACK_FIRST_RELEASE is not None + and version_tuple >= release_roster.RSTACK_FIRST_RELEASE + ): + for platform in rstack_components.PLATFORMS: + inventory[f"rstack-{tag}-{platform}"] = "binary" + inventory[f"rstack-{tag}-install.sh"] = "installer" + inventory["rstack-install.sh"] = "installer" if version_tuple >= THIRD_PARTY_NOTICES_MINIMUM_VERSION: inventory["THIRD_PARTY_NOTICES"] = "notice" if version_tuple >= MACOS_FIPS_BUNDLE_MINIMUM_VERSION: @@ -1261,7 +1270,8 @@ def validate_candidate_manifest( manifest = require_object( document, "manifest", - DOCS_V2_TOP_LEVEL_FIELDS if uses_release_docs else V2_TOP_LEVEL_FIELDS, + (DOCS_V2_TOP_LEVEL_FIELDS if uses_release_docs else V2_TOP_LEVEL_FIELDS) + | ({"components"} if parsed_version_hint is not None and release_roster.RSTACK_FIRST_RELEASE is not None and parsed_version_hint >= release_roster.RSTACK_FIRST_RELEASE else set()), ) if manifest["schema_version"] != V2_SCHEMA_VERSION: raise CandidateError( @@ -1442,6 +1452,16 @@ def validate_candidate_manifest( "payload inventory must be exactly the supported release set: " f"{'; '.join(details)}" ) + if ( + release_roster.RSTACK_FIRST_RELEASE is not None + and release_version >= release_roster.RSTACK_FIRST_RELEASE + ): + try: + expected_components = rstack_components.build_component_manifest(version, payloads) + except ValueError as exc: + raise CandidateError(f"component manifest does not match payloads: {exc}") from exc + if manifest["components"] != expected_components: + raise CandidateError("component manifest does not match payloads and release roster") images = require_list(manifest["images"], "images") if not images: diff --git a/release/scripts/release_roster.py b/release/scripts/release_roster.py index 4093d3256b..696a40843d 100644 --- a/release/scripts/release_roster.py +++ b/release/scripts/release_roster.py @@ -75,6 +75,9 @@ SCHEDULING_INSTALLER_FIRST_RELEASE: tuple[int, int, int] | None = (0, 42, 0) +# The first release with the unified local command and component manager. +RSTACK_FIRST_RELEASE: tuple[int, int, int] | None = (0, 44, 0) + COORDINATOR_CLIENT_FIRST_RELEASE: tuple[int, int, int] | None = None COORDINATOR_FIRST_RELEASE: tuple[int, int, int] | None = None @@ -147,6 +150,13 @@ def scheduling_installer_in_release(version: tuple[int, int, int]) -> bool: return first_release is not None and tuple(version) >= first_release +def rstack_in_release(version: tuple[int, int, int]) -> bool: + """Return whether the release ships rstack and its component model.""" + + first_release = RSTACK_FIRST_RELEASE + return first_release is not None and tuple(version) >= first_release + + def coordinator_client_in_release(version: tuple[int, int, int]) -> bool: """Return whether the unified client packages carry Registry Coordinator. @@ -245,6 +255,12 @@ def main(argv: list[str] | None = None) -> int: ) scheduling_installer.add_argument("version", type=version_argument, help="release version as X.Y.Z") scheduling_installer.set_defaults(in_release=scheduling_installer_in_release) + rstack = commands.add_parser( + "rstack-in-release", + help="print true when the release ships the rstack component manager", + ) + rstack.add_argument("version", type=version_argument, help="release version as X.Y.Z") + rstack.set_defaults(in_release=rstack_in_release) coordinator_client = commands.add_parser( "coordinator-client-in-release", help="print true when the unified client packages carry Registry Coordinator, else false", diff --git a/release/scripts/rstack_components.py b/release/scripts/rstack_components.py new file mode 100644 index 0000000000..73e38a7592 --- /dev/null +++ b/release/scripts/rstack_components.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Derive installable rstack components from the exact release payload records.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from typing import Any + + +PLATFORMS = ("linux-amd64", "linux-arm64", "macos-arm64") +SCHEMA_VERSION = "registry-stack.components.v1" +SHA256 = re.compile(r"[0-9a-f]{64}\Z") +VERSION = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\Z") + +# Binary names are the publication roster's exact asset stems. A product only +# becomes installable where all baseline binaries have been published. +PRODUCTS = { + "breg": { + "entrypoint": "bregctl", + "baseline": ("bregctl", "breg"), + "optional": ("breg-mcp", "breg-review"), + "runtime": "breg", + }, + "casework": { + "entrypoint": "caseworkctl", + "baseline": ("caseworkctl", "casework"), + "optional": (), + "runtime": "casework", + }, + "discovery": { + "entrypoint": "discoveryctl", + "baseline": ("discoveryctl", "discovery"), + "optional": (), + "runtime": "discovery", + }, + "evidence": { + "entrypoint": "evidencectl", + "baseline": ("evidencectl", "evidence"), + "optional": ("evidence-oid4vci",), + "runtime": "evidence", + }, + "manifest": { + "entrypoint": "registry-manifest", + "baseline": ("registry-manifest",), + "optional": (), + "runtime": None, + }, + "messaging": { + "entrypoint": "messagingctl", + "baseline": ("messagingctl", "messaging"), + "optional": (), + "runtime": "messaging", + }, + "render": { + "entrypoint": "registry-render", + "baseline": ("registry-render",), + "optional": (), + "runtime": "registry-render", + }, + "scheduling": { + "entrypoint": "schedulingctl", + "baseline": ("schedulingctl",), + "optional": ("scheduling",), + "runtime": "scheduling", + }, + "coordinator": { + "entrypoint": "coordinatorctl", + "baseline": ("coordinatorctl", "coordinator"), + "optional": (), + "runtime": "coordinator", + }, +} + + +def _binary_name(asset_name: str, tag: str) -> tuple[str, str]: + for platform in PLATFORMS: + suffix = f"-{tag}-{platform}" + if platform == "macos-arm64": + suffix += ".tar.gz" + if asset_name.endswith(suffix): + binary = asset_name[: -len(suffix)] + if binary: + return binary, platform + raise ValueError(f"unclassified release binary asset: {asset_name}") + + +def _asset(record: dict[str, Any]) -> dict[str, Any]: + name = record.get("name") + size = record.get("size") + digest = record.get("sha256") + if not isinstance(name, str) or not name or name != name.split("/")[-1]: + raise ValueError("binary asset name must be a public basename") + if isinstance(size, bool) or not isinstance(size, int) or size <= 0: + raise ValueError(f"binary asset {name} needs a positive size") + if not isinstance(digest, str) or SHA256.fullmatch(digest) is None: + raise ValueError(f"binary asset {name} needs a SHA-256 digest") + return {"name": name, "size": size, "sha256": digest} + + +def build_component_manifest(version: str, payloads: list[dict[str, Any]]) -> dict[str, Any]: + """Return the versioned component model or refuse roster drift. + + Every binary payload, including the launcher, must be classified exactly + once. Nonbinary payloads remain covered by the parent release manifest. + """ + + if VERSION.fullmatch(version) is None: + raise ValueError("release version must be canonical X.Y.Z") + tag = f"v{version}" + binaries: dict[str, dict[str, dict[str, Any]]] = {platform: {} for platform in PLATFORMS} + for record in payloads: + if record.get("kind") != "binary": + continue + asset = _asset(record) + binary, platform = _binary_name(asset["name"], tag) + if binary in binaries[platform]: + raise ValueError(f"duplicate {platform} binary {binary}") + binaries[platform][binary] = asset + + launcher: dict[str, dict[str, Any]] = {} + for platform in PLATFORMS: + if "rstack" not in binaries[platform]: + raise ValueError(f"incomplete launcher on {platform}: rstack is missing") + launcher[platform] = binaries[platform]["rstack"] + + classified = {"rstack"} + components: dict[str, dict[str, Any]] = {} + for name, spec in PRODUCTS.items(): + baseline = spec["baseline"] + optional = spec["optional"] + group = set(baseline) | set(optional) + classified.update(group) + supported: dict[str, dict[str, Any]] = {} + for platform, published in binaries.items(): + present = group & published.keys() + if not present: + continue + missing = set(baseline) - published.keys() + if missing: + raise ValueError(f"incomplete {name} baseline on {platform}: {sorted(missing)}") + selected = [published[binary] for binary in (*baseline, *optional) if binary in published] + supported[platform] = { + "assets": selected, + "capabilities": { + "tooling": True, + "runtime": spec["runtime"] in published if spec["runtime"] else False, + **{binary: binary in published for binary in optional}, + }, + } + if supported: + components[name] = { + "entrypoint": spec["entrypoint"], + "dependencies": [], + "platforms": supported, + } + + unknown = sorted({binary for group in binaries.values() for binary in group} - classified) + if unknown: + raise ValueError(f"unclassified release binaries: {unknown}") + return { + "schema_version": SCHEMA_VERSION, + "release": tag, + "launcher": launcher, + "components": components, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", required=True) + args = parser.parse_args() + try: + payloads = json.load(sys.stdin) + if not isinstance(payloads, list): + raise ValueError("payloads must be an array") + document = build_component_manifest(args.version, payloads) + except (json.JSONDecodeError, ValueError) as exc: + parser.exit(2, f"rstack component manifest: {exc}\n") + sys.stdout.write(json.dumps(document, separators=(",", ":"), sort_keys=True) + "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/release/scripts/test_release_candidate.py b/release/scripts/test_release_candidate.py index 166f7050dd..bb46db6c90 100644 --- a/release/scripts/test_release_candidate.py +++ b/release/scripts/test_release_candidate.py @@ -509,6 +509,9 @@ def make_v2_candidate(self) -> tuple[dict, Path, Path, dict]: "sha256": sha256(bundle_path.read_bytes()), }, } + candidate["components"] = self.module.rstack_components.build_component_manifest( + "1.2.3", candidate["payloads"] + ) run = { "id": 123, "run_attempt": 2, @@ -587,6 +590,7 @@ def test_v2_candidate_docs_contract_is_version_aware(self) -> None: v0_19_0 = copy.deepcopy(current) v0_19_0["release"]["version"] = "0.19.0" v0_19_0["release"]["tag"] = "v0.19.0" + v0_19_0.pop("components") with self.assertRaisesRegex( self.module.CandidateError, "manifest has a non-closed schema: unknown docs", @@ -621,6 +625,7 @@ def test_v2_candidate_payload_inventory_is_exact_and_excludes_registryctl( docs_payload = copy.deepcopy(candidate) docs_payload["release"]["version"] = "0.19.0" docs_payload["release"]["tag"] = "v0.19.0" + docs_payload.pop("components") docs_payload.pop("docs") docs_payload["payloads"] = [ item for item in docs_payload["payloads"] if item["kind"] != "docs" @@ -2039,6 +2044,9 @@ def test_v2_schema_and_runtime_require_one_named_security_evidence_payload( ) self.assertNotIn("docs", schema["required"]) self.assertIn("docs", schema["properties"]) + self.assertIn("components", schema["properties"]) + self.assertIn("components", schema["allOf"][1]["then"]["required"]) + self.assertIn("components", schema["allOf"][1]["else"]["not"]["required"]) self.assertIn( "docs", payload_schema["items"]["properties"]["kind"]["enum"] ) diff --git a/release/scripts/test_release_roster.py b/release/scripts/test_release_roster.py index 90ec644f53..0433f979bc 100644 --- a/release/scripts/test_release_roster.py +++ b/release/scripts/test_release_roster.py @@ -76,6 +76,19 @@ def test_scheduling_installer_is_published_from_v0_42(self) -> None: self.assertEqual(0, result) self.assertEqual(expected, stdout.getvalue()) + def test_rstack_component_manager_joins_v0_44(self) -> None: + self.assertEqual((0, 44, 0), release_roster.RSTACK_FIRST_RELEASE) + self.assertFalse(release_roster.rstack_in_release((0, 43, 0))) + self.assertTrue(release_roster.rstack_in_release((0, 44, 0))) + with mock.patch.object(release_roster, "RSTACK_FIRST_RELEASE", None): + self.assertFalse(release_roster.rstack_in_release((1, 0, 0))) + output = io.StringIO() + with redirect_stdout(output): + self.assertEqual( + release_roster.main(["rstack-in-release", "0.44.0"]), 0 + ) + self.assertEqual(output.getvalue(), "true\n") + def test_unified_clients_hold_coordinator_until_its_client_returns(self) -> None: self.assertIsNone(release_roster.COORDINATOR_CLIENT_FIRST_RELEASE) for version in ((0, 41, 0), (0, 42, 0), (1, 0, 0)): diff --git a/release/scripts/test_rstack_components.py b/release/scripts/test_rstack_components.py new file mode 100644 index 0000000000..bcad10d28f --- /dev/null +++ b/release/scripts/test_rstack_components.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""The rstack installation model must match the exact release payload roster.""" + +from __future__ import annotations + +import unittest + +import release_candidate +import rstack_components + + +def payloads(version: str) -> list[dict[str, object]]: + return [ + {"name": name, "kind": kind, "size": 17, "sha256": "a" * 64} + for name, kind in release_candidate._release_payload_inventory(version).items() + ] + + +class ComponentManifestTests(unittest.TestCase): + def test_release_inventory_ships_launcher_on_each_published_platform(self) -> None: + inventory = release_candidate._release_payload_inventory("0.44.0") + for platform in rstack_components.PLATFORMS: + self.assertEqual(inventory[f"rstack-v0.44.0-{platform}" + (".tar.gz" if platform == "macos-arm64" else "")], "binary") + self.assertEqual(inventory["rstack-v0.44.0-install.sh"], "installer") + self.assertEqual(inventory["rstack-install.sh"], "installer") + self.assertNotIn("rstack-v0.43.0-linux-amd64", release_candidate._release_payload_inventory("0.43.0")) + + def test_tooling_only_platform_and_absent_component_are_explicit(self) -> None: + document = rstack_components.build_component_manifest("0.44.0", payloads("0.44.0")) + self.assertEqual(document["schema_version"], "registry-stack.components.v1") + self.assertEqual(document["release"], "v0.44.0") + self.assertEqual(set(document["launcher"]), set(rstack_components.PLATFORMS)) + scheduling = document["components"]["scheduling"] + self.assertEqual(scheduling["entrypoint"], "schedulingctl") + self.assertFalse(scheduling["platforms"]["macos-arm64"]["capabilities"]["runtime"]) + self.assertTrue(scheduling["platforms"]["linux-amd64"]["capabilities"]["runtime"]) + self.assertNotIn("coordinator", document["components"]) + self.assertNotIn("macos-arm64", document["components"]["manifest"]["platforms"]) + + def test_asset_references_retain_size_and_digest(self) -> None: + document = rstack_components.build_component_manifest("0.44.0", payloads("0.44.0")) + asset = document["components"]["breg"]["platforms"]["linux-amd64"]["assets"][0] + self.assertEqual(asset["size"], 17) + self.assertEqual(asset["sha256"], "a" * 64) + self.assertTrue(asset["name"].endswith("-v0.44.0-linux-amd64")) + + def test_unclassified_or_incomplete_binary_roster_fails_closed(self) -> None: + actual = payloads("0.44.0") + with self.assertRaisesRegex(ValueError, "unclassified"): + rstack_components.build_component_manifest( + "0.44.0", actual + [{"name": "mystery-v0.44.0-linux-amd64", "kind": "binary", "size": 5, "sha256": "b" * 64}] + ) + without_ctl = [item for item in actual if item["name"] != "bregctl-v0.44.0-linux-amd64"] + with self.assertRaisesRegex(ValueError, "incomplete"): + rstack_components.build_component_manifest("0.44.0", without_ctl) + + +if __name__ == "__main__": + unittest.main() From e59deeae42f55769d0ddc444563a4954615e1182 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 02:57:01 +0700 Subject: [PATCH 2/9] fix: cover rstack installer in libc preflight Signed-off-by: Jeremi Joslin --- crates/registry-rstack/install.sh | 78 ++++++++++++++++++- .../render-installer-libc-preflight.py | 1 + 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/registry-rstack/install.sh b/crates/registry-rstack/install.sh index a562482ad6..aa467f7a47 100644 --- a/crates/registry-rstack/install.sh +++ b/crates/registry-rstack/install.sh @@ -145,6 +145,82 @@ Darwin:arm64) platform=macos-arm64 ;; ;; esac +repo="${repository}" +version="${release}" +os_label="${platform%%-*}" +# BEGIN generated libc preflight +# Generated from release/glibc-floor.env by +# release/scripts/render-installer-libc-preflight.py. Do not edit by hand. +# +# The published Linux binaries are dynamically linked against GNU libc. On a +# musl system, or on a glibc older than the floor below, they cannot start, and +# the dynamic linker only says so at the first run, long after this installer +# would have reported success. Refuse here instead, before anything downloads. +if [ "$os_label" = "linux" ]; then + libc_floor="2.34" + # musl's ldd exits non-zero when asked for a version, and this installer runs + # under pipefail, so its report is captured once here instead of being read + # through a pipeline whose status would hide the match. + libc_report="" + if command -v ldd >/dev/null 2>&1; then + libc_report="$(ldd --version 2>&1 || true)" + fi + # Only glibc's getconf answers GNU_LIBC_VERSION, so an answer names the libc + # this system runs on, whatever else is installed beside it: a musl loader + # kept for cross builds does not make a musl system. Without an answer, musl + # is recognised by its ldd report or its loader. The report is matched by a + # pattern rather than through grep, which can exit before the report is + # fully written and, under pipefail, lose the match. + detected_libc="" + if command -v getconf >/dev/null 2>&1; then + detected_libc="$(getconf GNU_LIBC_VERSION 2>/dev/null | awk '{print $2}' || true)" + fi + musl_system=0 + if [ -z "$detected_libc" ]; then + case "$libc_report" in + *[Mm][Uu][Ss][Ll]*) musl_system=1 ;; + esac + if ls /lib/ld-musl-*.so.1 >/dev/null 2>&1; then + musl_system=1 + fi + fi + if [ "$musl_system" = 1 ]; then + printf 'No musl build of Registry Stack is published for this platform.\n' >&2 + printf 'Every published Linux binary needs GNU libc %s or newer, so none of them can start here.\n' \ + "$libc_floor" >&2 + printf 'Install on a GNU libc distribution, or run the published container images.\n' >&2 + printf 'If you need musl builds, ask for them at https://github.com/%s/issues so the demand is recorded.\n' \ + "$repo" >&2 + exit 1 + fi + if [ -z "$detected_libc" ]; then + detected_libc="$(printf '%s\n' "$libc_report" | awk 'NR == 1 {print $NF}')" + fi + case "$detected_libc" in + [0-9]*.[0-9]*) ;; + *) detected_libc="" ;; + esac + if [ -z "$detected_libc" ]; then + printf 'Could not read the GNU libc version of this system, so the %s floor is unchecked.\n' \ + "$libc_floor" >&2 + elif ! awk -v have="$detected_libc" -v floor="$libc_floor" ' + BEGIN { + split(have, h, ".") + split(floor, f, ".") + exit !(h[1] > f[1] || (h[1] == f[1] && h[2] >= f[2])) + } + '; then + printf 'This system has GNU libc %s. The published binaries of Registry Stack need %s or newer.\n' \ + "$detected_libc" "$libc_floor" >&2 + printf 'Nothing was installed: the binaries would fail to start with a dynamic linker error.\n' >&2 + printf 'Upgrade the distribution, or run the published container images.\n' >&2 + exit 1 + fi +fi + +# END generated libc preflight +base_url="https://github.com/${repo}/releases/download/${version}" + launcher_name="rstack-${release}-${platform}" if [[ "${platform}" == macos-arm64 ]]; then launcher_name+=".tar.gz" @@ -174,7 +250,7 @@ fetch_asset() { curl --fail --silent --show-error --location --proto '=https' \ --proto-redir '=https' --max-time 300 \ --output "${scratch}/${name}" \ - "https://github.com/${repository}/releases/download/${release}/${name}" + "${base_url}/${name}" fi } diff --git a/release/scripts/render-installer-libc-preflight.py b/release/scripts/render-installer-libc-preflight.py index 1f8b669ef3..bc57b6d56a 100755 --- a/release/scripts/render-installer-libc-preflight.py +++ b/release/scripts/render-installer-libc-preflight.py @@ -40,6 +40,7 @@ def __init__(self, path: str, product: str) -> None: Installer("crates/registry-casework/install.sh", "Registry Casework"), Installer("crates/registry-scheduling/install.sh", "Registry Scheduling"), Installer("crates/registry-evidencectl/install.sh", "the Evidence toolset"), + Installer("crates/registry-rstack/install.sh", "Registry Stack"), ) TEMPLATE = """\ From 8b4276e23909e9a4c4c38ec3a1d151a07e4980cb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 03:01:33 +0700 Subject: [PATCH 3/9] fix: wire rstack release tests into CI Signed-off-by: Jeremi Joslin --- .github/workflows/ci.yml | 3 +++ release/scripts/test_release_rehearsal.py | 1 + release/scripts/test_rstack_components.py | 3 +-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2c96bcc53..89bdfa44cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1778,6 +1778,9 @@ jobs: - name: Test release candidate manifest and promotion verifier run: python3 -m unittest release/scripts/test_release_candidate.py + - name: Test rstack release component inventory + run: python3 -m unittest release/scripts/test_rstack_components.py + - name: Test public release verifier run: python3 -m unittest release/scripts/test_verify_public_release.py diff --git a/release/scripts/test_release_rehearsal.py b/release/scripts/test_release_rehearsal.py index 59483c5eea..14401446bf 100644 --- a/release/scripts/test_release_rehearsal.py +++ b/release/scripts/test_release_rehearsal.py @@ -29,6 +29,7 @@ def test_advisory_bootstrap_cannot_qualify_as_normal_rehearsal(self) -> None: for relative in ( "release/scripts/release_candidate.py", "release/scripts/release_roster.py", + "release/scripts/rstack_components.py", "release/scripts/build-release-binaries.sh", "release/scripts/build-release-image.sh", "release/scripts/cleanup-release-candidates.py", diff --git a/release/scripts/test_rstack_components.py b/release/scripts/test_rstack_components.py index bcad10d28f..1248e14606 100644 --- a/release/scripts/test_rstack_components.py +++ b/release/scripts/test_rstack_components.py @@ -5,8 +5,7 @@ import unittest -import release_candidate -import rstack_components +from release.scripts import release_candidate, rstack_components def payloads(version: str) -> list[dict[str, object]]: From 4e26279d0a2cac326f6126d0862d30aac149f746 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 03:13:02 +0700 Subject: [PATCH 4/9] fix: align manifest fuzz lock with CLI dependencies Signed-off-by: Jeremi Joslin --- products/manifest/fuzz/Cargo.lock | 127 ++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/products/manifest/fuzz/Cargo.lock b/products/manifest/fuzz/Cargo.lock index 8c9de59d59..26201c3cb9 100644 --- a/products/manifest/fuzz/Cargo.lock +++ b/products/manifest/fuzz/Cargo.lock @@ -2,6 +2,56 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -47,6 +97,52 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-oid" version = "0.10.2" @@ -129,6 +225,12 @@ dependencies = [ "r-efi", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hybrid-array" version = "0.4.13" @@ -267,6 +369,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -317,6 +425,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oxiri" version = "0.3.1" @@ -371,6 +485,7 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" name = "registry-manifest-cli" version = "0.44.0" dependencies = [ + "clap", "registry-manifest-core", "registry-platform-buildinfo", "registry-platform-config", @@ -556,6 +671,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "syn" version = "2.0.119" @@ -650,6 +771,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "windows-link" version = "0.2.1" From c8897340aa163fe618edeb9c0c585a8d9250b916 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 03:15:16 +0700 Subject: [PATCH 5/9] fix: check release notes against frozen manifests Signed-off-by: Jeremi Joslin --- .../site/scripts/release-notes-drift.test.mjs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/site/scripts/release-notes-drift.test.mjs b/docs/site/scripts/release-notes-drift.test.mjs index 464d0d8587..0e4958db87 100644 --- a/docs/site/scripts/release-notes-drift.test.mjs +++ b/docs/site/scripts/release-notes-drift.test.mjs @@ -48,22 +48,23 @@ function newestVersion(versions, label) { return versions.toSorted(compareSemver).at(-1); } -function latestStackReleaseVersion() { - const versions = readdirSync(resolve(repoRoot, 'release/notes')) - .map((name) => /^v([0-9]+\.[0-9]+\.[0-9]+)[.]md$/.exec(name)?.[1]) - .filter(Boolean); - return newestVersion(versions, 'release/notes'); -} - function latestStackManifest() { - const version = latestStackReleaseVersion(); const manifests = readdirSync(resolve(repoRoot, 'release/manifests')) .filter((name) => /^registry-stack-.+[.]yaml$/.test(name)) .map((name) => parse(readRepoFile(`release/manifests/${name}`))) - .filter((manifest) => manifest?.stack?.version === version); + .filter((manifest) => manifest?.stack?.version); + const version = newestVersion( + manifests.map((manifest) => manifest.stack.version), + 'release/manifests', + ); + const latest = manifests.filter((manifest) => manifest.stack.version === version); - assert.equal(manifests.length, 1, `expected one release manifest for ${version}`); - return manifests[0]; + assert.equal(latest.length, 1, `expected one release manifest for ${version}`); + assert.ok( + readdirSync(resolve(repoRoot, 'release/notes')).includes(`v${version}.md`), + `expected release notes for published manifest ${version}`, + ); + return latest[0]; } test('product release notes track newest released changelog headings', () => { From 81425a5685272de2e503824c5978e8dfc166188f Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 03:29:18 +0700 Subject: [PATCH 6/9] fix: refresh runtime schema catalog digests Signed-off-by: Jeremi Joslin --- products/identifiers/generated/catalog.v1.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index 6eecf5ba83..bf2a992baa 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -3835,7 +3835,7 @@ }, "artifact": { "path": "products/breg/generated/mcp-runtime/mcp-runtime.schema.json", - "sha256": "e7ebb4c45984c87919a5c3b7d886488550bd4e45ec4a0213dc80dd2a722acc2e", + "sha256": "34d05bd2adc4b41a65becd76fa288943d2636b7b60e4cfaf17c026dbd962b0f1", "mediaType": "application/schema+json" } }, @@ -3979,7 +3979,7 @@ }, "artifact": { "path": "products/breg/generated/review-runtime/review-runtime.schema.json", - "sha256": "25b93baf3a412d22d90d4d224560a18ae2d5499d8da11a93d090f4b0178c0002", + "sha256": "1c547522ee09c598590eaa302615aaabb5e5628516f85f2f5558989af25210b1", "mediaType": "application/schema+json" } }, @@ -4015,7 +4015,7 @@ }, "artifact": { "path": "products/breg/generated/runtime/runtime.schema.json", - "sha256": "5d5ae3f256d6cbfa520d08836428edbcc9f62972155e4875336b25ae2af8cd3e", + "sha256": "98d90628f2edef3f491e885ce72415c8c2e1098adc1a71b74ce6b8ba44b6bccf", "mediaType": "application/schema+json" } }, From 330966941a99757afff70486c2774fef945ac5c4 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 03:38:35 +0700 Subject: [PATCH 7/9] fix: enforce rstack installer and serve invariants Signed-off-by: Jeremi Joslin --- crates/registry-caseworkctl/src/lib.rs | 62 ++++++- crates/registry-rstack/install.sh | 18 +- crates/registry-rstack/src/install.rs | 32 ++-- .../registry-rstack/tests/install_script.rs | 170 ++++++++++++++++++ crates/registry-rstack/tests/manager.rs | 68 +++++++ 5 files changed, 331 insertions(+), 19 deletions(-) create mode 100644 crates/registry-rstack/tests/install_script.rs diff --git a/crates/registry-caseworkctl/src/lib.rs b/crates/registry-caseworkctl/src/lib.rs index 9466ba7c55..dd917c2fb6 100644 --- a/crates/registry-caseworkctl/src/lib.rs +++ b/crates/registry-caseworkctl/src/lib.rs @@ -229,6 +229,16 @@ pub fn main_entry_from( stderr: &mut impl Write, ) -> ExitCode { let args: Vec<_> = args.into_iter().collect(); + let format_supplied = args + .iter() + .skip(1) + .take_while(|arg| arg.as_os_str() != "--") + .any(|arg| { + arg.as_os_str() == "--format" + || arg + .to_str() + .is_some_and(|value| value.starts_with("--format=")) + }); let json_output = args.iter().enumerate().any(|(index, arg)| { arg == "--format=json" || (arg == "--format" && args.get(index + 1).is_some_and(|value| value == "json")) @@ -262,7 +272,7 @@ pub fn main_entry_from( }; let Cli { format, command } = cli; let command = match command { - Command::Serve(args) => return serve(args, format, stdout, stderr), + Command::Serve(args) => return serve(args, format, format_supplied, stdout, stderr), command => command, }; let kind = command_kind(&command); @@ -589,20 +599,27 @@ fn run(command: Command) -> Result { fn serve( args: ServeArgs, format: OutputFormat, + format_supplied: bool, stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> ExitCode { - if matches!(format, OutputFormat::Json) { + if format_supplied { let diagnostic = registry_platform_yaml::Diagnostic::error( "casework.usage.invalid", "format", "--format does not apply to the serve command because service logs keep the Casework runtime contract.", "Remove --format and retry the serve command.", ); - let result = - report::CliReport::failure("usage", report::ReportStatus::UsageError, vec![diagnostic]) - .and_then(|report| serde_json::to_value(report).map_err(Into::into)) - .and_then(|value| report::write(&value, stdout).map_err(Into::into)); + let result: Result<()> = match format { + OutputFormat::Human => writeln!(stderr, "{}", diagnostic.message).map_err(Into::into), + OutputFormat::Json => report::CliReport::failure( + "usage", + report::ReportStatus::UsageError, + vec![diagnostic], + ) + .and_then(|report| serde_json::to_value(report).map_err(Into::into)) + .and_then(|value| report::write(&value, stdout).map_err(Into::into)), + }; if result.is_err() { let _ = writeln!(stderr, "caseworkctl: output could not be written"); return ExitCode::from(OPERATIONAL_FAILURE_EXIT); @@ -774,6 +791,39 @@ pub(crate) fn canonical_tempdir() -> tempfile::TempDir { mod tests { use super::*; #[test] + fn serve_refuses_explicit_human_format_before_starting_the_runtime() { + for arguments in [ + vec![ + OsString::from("caseworkctl"), + OsString::from("--format"), + OsString::from("human"), + OsString::from("serve"), + OsString::from("--runtime-config"), + OsString::from("/tmp/runtime.yaml"), + OsString::from("--casework-bin"), + OsString::from("/definitely/missing-casework"), + ], + vec![ + OsString::from("caseworkctl"), + OsString::from("serve"), + OsString::from("--runtime-config"), + OsString::from("/tmp/runtime.yaml"), + OsString::from("--casework-bin"), + OsString::from("/definitely/missing-casework"), + OsString::from("--format"), + OsString::from("human"), + ], + ] { + let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); + let result = main_entry_from(arguments, &mut stdout, &mut stderr); + + assert_eq!(result, ExitCode::from(report::USAGE_EXIT)); + assert!(stdout.is_empty()); + let refusal = String::from_utf8(stderr).unwrap(); + assert!(refusal.contains("--format does not apply to the serve command")); + } + } + #[test] fn removed_commands_are_not_exposed() { for name in ["source", "attempt", "retention", "simulate"] { assert!(Cli::try_parse_from(["caseworkctl", name]).is_err()); diff --git a/crates/registry-rstack/install.sh b/crates/registry-rstack/install.sh index aa467f7a47..3e1da5e02f 100644 --- a/crates/registry-rstack/install.sh +++ b/crates/registry-rstack/install.sh @@ -38,8 +38,8 @@ EOF } release="${RSTACK_RELEASE:-${default_version}}" -bin_dir="${HOME:?HOME is required}/.local/bin" -install_root="${HOME:?HOME is required}/.local/lib/registrystack" +bin_dir="" +install_root="" asset_dir="" selected=() select_all=0 @@ -134,6 +134,18 @@ if [[ "${seen_release}" -eq 1 && -n "${RSTACK_RELEASE:-}" && "${RSTACK_RELEASE}" printf -- '--release conflicts with RSTACK_RELEASE.\n' >&2 exit 2 fi +if [[ "${seen_bin_dir}" -eq 0 || "${seen_install_root}" -eq 0 ]]; then + if [[ -z "${HOME:-}" ]]; then + printf 'HOME is required for default installation paths; pass --bin-dir and --install-root.\n' >&2 + exit 2 + fi + if [[ "${seen_bin_dir}" -eq 0 ]]; then + bin_dir="${HOME}/.local/bin" + fi + if [[ "${seen_install_root}" -eq 0 ]]; then + install_root="${HOME}/.local/lib/registrystack" + fi +fi case "$(uname -s):$(uname -m)" in Linux:x86_64) platform=linux-amd64 ;; @@ -357,7 +369,7 @@ if [[ -n "${asset_dir}" ]]; then fi if [[ "${select_all}" -eq 1 ]]; then arguments+=(--all) -else +elif [[ "${#selected[@]}" -gt 0 ]]; then arguments+=("${selected[@]}") fi "${launcher}" "${arguments[@]}" diff --git a/crates/registry-rstack/src/install.rs b/crates/registry-rstack/src/install.rs index 81a39eac4e..303191beed 100644 --- a/crates/registry-rstack/src/install.rs +++ b/crates/registry-rstack/src/install.rs @@ -196,6 +196,10 @@ pub fn bootstrap(options: BootstrapOptions) -> Result { let store = GenerationStore::new(options.install_root, options.bin_dir); let mutation = store.lock_mutation()?; + if let Some(current) = mutation.current()? { + let active = read_active_release(current.path())?; + ensure_not_downgrade(&active.selection.release, &manifest.release.tag)?; + } let name = unique_generation_name(&manifest.release.tag)?; let staged = mutation.stage_generation(&name)?; let raw_dir = staged.path().join(".assets"); @@ -397,12 +401,16 @@ pub fn update( let plan = prepare_update(&active, release, options.asset_dir.clone())?; return Ok(UpdateReport { schema_version: "registry.rstack.update.v1", - current_release: active.selection.release, + current_release: active.selection.release.clone(), target_release: release.to_owned(), generation: None, components: plan.components, added_components: plan.added_components, - download_bytes: plan.download_bytes, + download_bytes: if release == active.selection.release { + 0 + } else { + plan.download_bytes + }, preview: true, activated: false, }); @@ -665,14 +673,7 @@ fn prepare_update( release: &str, asset_dir: Option, ) -> Result { - let current_version = parse_release_version(&active.selection.release)?; - let target_version = parse_release_version(release).map_err(ManagerError::domain)?; - if target_version < current_version { - return Err(ManagerError::domain(format!( - "refusing downgrade from {} to {release}", - active.selection.release - ))); - } + ensure_not_downgrade(&active.selection.release, release).map_err(ManagerError::domain)?; let target = resolve_release(release, asset_dir)?; if release == active.selection.release && target.manifest_bytes != active.manifest_bytes { return Err(ManagerError::operational( @@ -720,6 +721,17 @@ fn prepare_update( }) } +fn ensure_not_downgrade(current_release: &str, target_release: &str) -> Result<(), String> { + let current_version = parse_release_version(current_release)?; + let target_version = parse_release_version(target_release)?; + if target_version < current_version { + return Err(format!( + "refusing downgrade from {current_release} to {target_release}" + )); + } + Ok(()) +} + fn resolve_release( release: &str, asset_dir: Option, diff --git a/crates/registry-rstack/tests/install_script.rs b/crates/registry-rstack/tests/install_script.rs new file mode 100644 index 0000000000..ff5ba43029 --- /dev/null +++ b/crates/registry-rstack/tests/install_script.rs @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use tempfile::TempDir; + +const TEST_RELEASE: &str = "v9.8.7"; + +#[test] +fn help_does_not_require_home() { + let output = Command::new("bash") + .arg(installer_path()) + .arg("--help") + .env_remove("HOME") + .output() + .unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("--install-root DIR")); +} + +#[test] +fn explicit_install_paths_do_not_require_home() { + let fixture = InstallerFixture::new(); + let output = fixture.command().output().unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let arguments = fs::read_to_string(&fixture.arguments).unwrap(); + assert!( + arguments.contains(&format!("--bin-dir\n{}\n", fixture.bin_dir.display())), + "launcher arguments: {arguments}" + ); + assert!( + arguments.contains(&format!( + "--install-root\n{}\n", + fixture.install_root.display() + )), + "launcher arguments: {arguments}" + ); +} + +struct InstallerFixture { + _temp: TempDir, + assets: PathBuf, + tools: PathBuf, + arguments: PathBuf, + bin_dir: PathBuf, + install_root: PathBuf, +} + +impl InstallerFixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let assets = temp.path().join("assets"); + let tools = temp.path().join("tools"); + let archive_root = temp.path().join("archive"); + fs::create_dir_all(&assets).unwrap(); + fs::create_dir_all(&tools).unwrap(); + fs::create_dir_all(&archive_root).unwrap(); + + write_executable( + &tools.join("uname"), + "#!/bin/sh\ncase \"$1\" in -s) printf 'Darwin\\n' ;; -m) printf 'arm64\\n' ;; *) exit 1 ;; esac\n", + ); + write_executable( + &tools.join("shasum"), + &format!( + "#!/bin/sh\ncase \"$3\" in *release-manifest.json) printf '{} %s\\n' \"$3\" ;; *rstack-{TEST_RELEASE}-macos-arm64.tar.gz) printf '{} %s\\n' \"$3\" ;; *) exit 1 ;; esac\n", + "a".repeat(64), + "b".repeat(64), + ), + ); + + let launcher_name = format!("rstack-{TEST_RELEASE}-macos-arm64"); + let launcher = archive_root.join(&launcher_name); + write_executable( + &launcher, + "#!/bin/sh\nif [ \"${1:-}\" = --version ]; then printf 'rstack 9.8.7\\n'; exit 0; fi\nprintf '%s\\n' \"$@\" > \"${RSTACK_TEST_ARGUMENTS:?}\"\n", + ); + fs::write(archive_root.join("THIRD_PARTY_NOTICES"), "test notices\n").unwrap(); + fs::write( + archive_root.join("libaws_lc_fips_test.dylib"), + "test module\n", + ) + .unwrap(); + let archive = assets.join(format!("{launcher_name}.tar.gz")); + let status = Command::new("tar") + .args(["-czf"]) + .arg(&archive) + .arg("-C") + .arg(&archive_root) + .args([ + &launcher_name, + "THIRD_PARTY_NOTICES", + "libaws_lc_fips_test.dylib", + ]) + .status() + .unwrap(); + assert!(status.success()); + + let manifest_name = format!("registry-stack-{TEST_RELEASE}-release-manifest.json"); + fs::write(assets.join(&manifest_name), "{}\n").unwrap(); + fs::write( + assets.join("SHA256SUMS"), + format!( + "{} {manifest_name}\n{} {launcher_name}.tar.gz\n", + "a".repeat(64), + "b".repeat(64), + ), + ) + .unwrap(); + + let arguments = temp.path().join("arguments"); + let bin_dir = temp.path().join("public-bin"); + let install_root = temp.path().join("install-root"); + Self { + _temp: temp, + assets, + tools, + arguments, + bin_dir, + install_root, + } + } + + fn command(&self) -> Command { + let mut paths = vec![self.tools.clone()]; + paths.extend(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + )); + let mut command = Command::new("bash"); + command + .arg(installer_path()) + .args(["--release", TEST_RELEASE, "--asset-dir"]) + .arg(&self.assets) + .arg("--bin-dir") + .arg(&self.bin_dir) + .arg("--install-root") + .arg(&self.install_root) + .env("PATH", std::env::join_paths(paths).unwrap()) + .env("RSTACK_TEST_ARGUMENTS", &self.arguments) + .env_remove("HOME") + .env_remove("RSTACK_RELEASE"); + command + } +} + +fn installer_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("install.sh") +} + +fn write_executable(path: &Path, contents: &str) { + fs::write(path, contents).unwrap(); + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} diff --git a/crates/registry-rstack/tests/manager.rs b/crates/registry-rstack/tests/manager.rs index 68b2d706b3..a6e7530a21 100644 --- a/crates/registry-rstack/tests/manager.rs +++ b/crates/registry-rstack/tests/manager.rs @@ -677,6 +677,35 @@ fn update_preview_reports_dependency_changes_without_mutation() { ); } +#[test] +fn update_preview_of_the_selected_release_reports_no_download() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + + let report = update(&fixture.manager_options(), "v0.44.0", true).unwrap(); + + assert_eq!(report.current_release, "v0.44.0"); + assert_eq!(report.target_release, "v0.44.0"); + assert_eq!(report.components, vec!["evidence"]); + assert!(report.added_components.is_empty()); + assert_eq!(report.download_bytes, 0); + assert!(report.preview); + assert!(!report.activated); + assert_eq!(report.generation, None); + assert_eq!( + fixture + .store() + .acquire_current() + .unwrap() + .generation() + .name(), + before_name + ); +} + #[test] fn update_activates_complete_target_release_with_installed_set_and_new_dependencies() { let fixture = Fixture::new(); @@ -756,6 +785,45 @@ fn update_refuses_downgrade_and_unavailable_installed_component() { ); } +#[test] +fn bootstrap_refuses_to_replace_a_newer_selected_release() { + let fixture = Fixture::new(); + fixture.bootstrap(&["evidence"]); + let original_checksums = fs::read(&fixture.checksums).unwrap(); + fixture.write_release("v0.45.0", false, true); + update(&fixture.manager_options(), "v0.45.0", false).unwrap(); + let before = fixture.store().acquire_current().unwrap(); + let before_name = before.generation().name().to_owned(); + drop(before); + let generation_count = fs::read_dir(fixture.store().generations_dir()) + .unwrap() + .count(); + fs::write(&fixture.checksums, original_checksums).unwrap(); + + let error = bootstrap(BootstrapOptions { + release_manifest: fixture.manifest.clone(), + checksum_file: fixture.checksums.clone(), + launcher_asset: fixture.launcher.clone(), + asset_dir: Some(fixture.assets.clone()), + install_root: fixture.install_root.clone(), + bin_dir: fixture.bin_dir.clone(), + components: vec!["evidence".to_owned()], + all: false, + }) + .unwrap_err(); + + assert!(error.contains("downgrade"), "unexpected error: {error}"); + let current = fixture.store().acquire_current().unwrap(); + assert_eq!(current.generation().name(), before_name); + assert_eq!(read_selection(current.path()).unwrap().release, "v0.45.0"); + assert_eq!( + fs::read_dir(fixture.store().generations_dir()) + .unwrap() + .count(), + generation_count + ); +} + #[test] fn corrupt_target_payload_preserves_current_release() { let fixture = Fixture::new(); From 1619581663da6242b1bdc9a11826602e97543829 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 04:16:29 +0700 Subject: [PATCH 8/9] fix: align Render reports and preserve product formats Signed-off-by: Jeremi Joslin --- crates/registry-cli-docs/catalog.json | 83 +++++-- crates/registry-cli-docs/src/lib.rs | 39 +++- crates/registry-render/src/check.rs | 2 +- crates/registry-render/src/cli.rs | 100 +++++---- crates/registry-render/src/init.rs | 4 +- crates/registry-render/src/main.rs | 72 +++++- crates/registry-render/src/problem.rs | 4 +- crates/registry-render/tests/scaffold.rs | 211 ++++++++++++++++-- crates/registry-rstack/install.sh | 5 + crates/registry-rstack/src/cli.rs | 165 +++++++++++--- crates/registry-rstack/tests/cli.rs | 17 ++ crates/registry-rstack/tests/dispatch.rs | 87 ++++++++ .../registry-rstack/tests/install_script.rs | 39 ++++ .../docs/tutorials/first-render-document.mdx | 2 +- products/render/DEFINITION-OF-DONE.md | 2 +- products/render/README.md | 8 +- 16 files changed, 718 insertions(+), 122 deletions(-) diff --git a/crates/registry-cli-docs/catalog.json b/crates/registry-cli-docs/catalog.json index 3572d232dd..596c97cbfb 100644 --- a/crates/registry-cli-docs/catalog.json +++ b/crates/registry-cli-docs/catalog.json @@ -16383,17 +16383,22 @@ "environment": null }, { - "display": "--json", - "description": "Machine-readable result on stdout (failures: problem+json on stderr, typed exit code)", + "display": "--format ", + "description": "Emit the selected command's finite report in this format", "always_required": false, "repeatable": false, - "default_values": [], - "possible_values": [], + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], "environment": null }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -16457,7 +16462,7 @@ "invocation": "registry-render compile", "about": "Render one document offline", "long_about": null, - "usage": "registry-render compile [OPTIONS] --bundle --type --data --out ", + "usage": "registry-render compile [OPTIONS] --bundle --type --data --output ", "arguments": [], "options": [ { @@ -16551,7 +16556,7 @@ "environment": null }, { - "display": "--out ", + "display": "--output ", "description": "Output PDF path", "always_required": true, "repeatable": false, @@ -16560,12 +16565,17 @@ "environment": null }, { - "display": "--json", - "description": "Machine-readable result on stdout", + "display": "--format ", + "description": "Emit the selected command's finite report in this format", "always_required": false, "repeatable": false, - "default_values": [], - "possible_values": [], + "default_values": [ + "human" + ], + "possible_values": [ + "human", + "json" + ], "environment": null }, { @@ -16579,7 +16589,7 @@ }, { "display": "-h, --help", - "description": "Print help", + "description": "Print help (see a summary with '-h')", "always_required": false, "repeatable": false, "default_values": [], @@ -16679,7 +16689,7 @@ "options": [ { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ @@ -16749,7 +16759,7 @@ "options": [ { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ @@ -16810,7 +16820,7 @@ "options": [ { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ @@ -16893,7 +16903,7 @@ }, { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ @@ -16973,7 +16983,7 @@ }, { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ @@ -17054,7 +17064,7 @@ }, { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ @@ -17116,7 +17126,7 @@ "options": [ { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ @@ -17178,7 +17188,7 @@ "options": [ { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ @@ -17230,6 +17240,37 @@ "constraints": [], "subcommands": [] }, + { + "name": "help", + "invocation": "rstack help", + "about": "Show offline product help", + "long_about": null, + "usage": "help [PRODUCT]", + "arguments": [ + { + "display": "[PRODUCT]", + "description": "Product whose offline command reference to show", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "options": [ + { + "display": "-h, --help", + "description": "Print help", + "always_required": false, + "repeatable": false, + "default_values": [], + "possible_values": [], + "environment": null + } + ], + "constraints": [], + "subcommands": [] + }, { "name": "completions", "invocation": "rstack completions", @@ -17250,7 +17291,7 @@ "options": [ { "display": "--format ", - "description": "Report encoding. Defaults to human even when stdout is piped", + "description": "Report encoding. Defaults to human even when stdout is piped. Evidence test and fixtures run also accept junit", "always_required": false, "repeatable": true, "default_values": [ diff --git a/crates/registry-cli-docs/src/lib.rs b/crates/registry-cli-docs/src/lib.rs index 51f59f9849..5f4fa76d07 100644 --- a/crates/registry-cli-docs/src/lib.rs +++ b/crates/registry-cli-docs/src/lib.rs @@ -23,7 +23,7 @@ pub fn catalog() -> Catalog { command_reference(registry_messaging::runtime::command(), None, None), command_reference(registry_messagingctl::command(), None, None), command_reference(registry_manifest_cli::command(), None, None), - command_reference(registry_rstack::command(), None, None), + rstack_reference(), command_reference(registry_breg::command(), None, None), command_reference(registry_breg_mcp::command(), None, None), command_reference(registry_breg_review::command(), None, None), @@ -42,6 +42,26 @@ pub fn catalog() -> Catalog { } } +/// Build the launcher reference with its product-owned offline help command. +/// The shared walker omits commands named `help` because Clap normally adds +/// one synthetically, while `rstack` disables that command and defines its own. +fn rstack_reference() -> CommandReference { + let command = registry_rstack::command(); + let (help_index, help) = command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .enumerate() + .find(|(_, subcommand)| subcommand.get_name() == "help") + .expect("rstack command tree publishes offline help"); + let help = help.clone(); + let mut reference = command_reference(command, None, None); + reference.subcommands.insert( + help_index, + command_reference(help, Some(&reference.invocation), None), + ); + reference +} + /// The committed catalog snapshot the docs site renders, beside this crate's /// manifest. pub fn snapshot_path() -> PathBuf { @@ -855,16 +875,21 @@ mod tests { option.display ); assert!( - option.description == "Runtime configuration file" - || option.description - == format!( - "Runtime configuration file. {}", - registry_cli_reference::SYMBOLIC_LINK_REFUSAL - ), + option.description.starts_with("Runtime configuration file"), "{}: {}", command.invocation, option.description ); + if command.invocation == "bregctl" || command.invocation.starts_with("bregctl ") + { + assert_eq!( + option.description.matches(SYMBOLIC_LINK_REFUSAL).count(), + 1, + "{}: {}", + command.invocation, + option.description + ); + } } } assert!(!command diff --git a/crates/registry-render/src/check.rs b/crates/registry-render/src/check.rs index 40c709aad5..c1032c0f54 100644 --- a/crates/registry-render/src/check.rs +++ b/crates/registry-render/src/check.rs @@ -7,7 +7,7 @@ //! with no package, secret material, or listener. //! The rendered file closure is governed where a render exists to capture //! it: the golden suite pins each acceptance bundle's closure and proves -//! every file it reads is manifest-governed, and `compile --json` reports +//! every file it reads is manifest-governed, and `compile --format json` reports //! `deps` for authors reviewing their own bundles. use std::collections::BTreeSet; diff --git a/crates/registry-render/src/cli.rs b/crates/registry-render/src/cli.rs index 9046ba299a..60e7fd0509 100644 --- a/crates/registry-render/src/cli.rs +++ b/crates/registry-render/src/cli.rs @@ -101,10 +101,9 @@ pub enum Command { /// either way. #[arg(long)] locale: Option, - /// Machine-readable result on stdout (failures: problem+json on - /// stderr, typed exit code). - #[arg(long)] - json: bool, + /// Emit the selected command's finite report in this format. + #[arg(long, value_enum, default_value_t = crate::check::OutputFormat::Human)] + format: crate::check::OutputFormat, }, /// Build a new deployment package with the shared checksum envelope. Package { @@ -166,10 +165,10 @@ pub enum Command { watch: bool, /// Output PDF path. #[arg(long)] - out: PathBuf, - /// Machine-readable result on stdout. - #[arg(long)] - json: bool, + output: PathBuf, + /// Emit the selected command's finite report in this format. + #[arg(long, value_enum, default_value_t = crate::check::OutputFormat::Human)] + format: crate::check::OutputFormat, /// Write the exact canonical envelope bytes injected into the /// template to this path (merge-gate and debugging aid). #[arg(long)] @@ -211,41 +210,58 @@ fn parse_asset_arg(spec: &str) -> Result<(String, PathBuf), String> { } /// Run the CLI. Returns the process exit code. -pub fn run(cli: Cli) -> i32 { +pub fn run(cli: Cli, format_supplied: bool) -> i32 { let json = wants_json(&cli); - match run_inner(cli) { + let invocation_error = + if format_supplied && matches!(&cli.command, Command::Compile { watch: true, .. }) { + Some("`--format` does not apply to `compile --watch`; remove it and retry") + } else { + None + }; + let result = match invocation_error { + Some(message) => Err(RenderProblem::new( + crate::problem::ProblemKind::InvalidArgument, + message, + )), + None => run_inner(cli), + }; + match result { Ok(code) => code, - Err(problem) => { - if json { - // The same RFC 9457 document HTTP callers get, on stderr; - // stdout stays for successful output. Scripts branch on the - // typed exit code and read the detail here. - let document = serde_json::json!({ - "type": format!("{}/{}", crate::problem::PROBLEM_TYPE_BASE, problem.kind.slug()), - "title": problem.kind.slug(), - "detail": problem.detail, - "pointers": problem.pointers, - "locations": problem.locations, - "diagnostics": problem.diagnostics, - }); - eprintln!( - "{}", - serde_json::to_string(&document).expect("problem json") - ); - } else { - eprint!("{}", problem.render_human()); - } - problem.exit_code() - } + Err(problem) => report_problem(problem, json), } } +/// Emit a refusal in the caller's selected report format and return its typed +/// process exit code. +pub fn report_problem(problem: RenderProblem, json: bool) -> i32 { + if json { + // The same RFC 9457 document HTTP callers get. Finite JSON reports use + // stdout for both success and refusal, with no progress text on stderr. + let document = serde_json::json!({ + "type": format!("{}/{}", crate::problem::PROBLEM_TYPE_BASE, problem.kind.slug()), + "title": problem.kind.slug(), + "detail": problem.detail, + "pointers": problem.pointers, + "locations": problem.locations, + "diagnostics": problem.diagnostics, + }); + println!( + "{}", + serde_json::to_string(&document).expect("problem json") + ); + } else { + eprint!("{}", problem.render_human()); + } + problem.exit_code() +} + /// Whether the invoked command asked for JSON output (its failures then /// print as problem documents instead of prose). fn wants_json(cli: &Cli) -> bool { match &cli.command { - Command::Compile { json, .. } => *json, - Command::Validate { json, .. } => *json, + Command::Compile { format, .. } | Command::Validate { format, .. } => { + matches!(format, crate::check::OutputFormat::Json) + } _ => false, } } @@ -322,7 +338,7 @@ fn run_inner(cli: Cli) -> Result { document, data, locale, - json, + format, } => { let bundle = Bundle::load_for_preview(&bundle)?; let document = bundle.document(&document)?; @@ -330,7 +346,7 @@ fn run_inner(cli: Cli) -> Result { // renders nothing. let request = read_request_without_time(&data, &locale)?; validate_data(document, &request)?; - if json { + if matches!(format, crate::check::OutputFormat::Json) { println!("{}", serde_json::json!({"valid": true})); } else { println!("ok"); @@ -348,15 +364,15 @@ fn run_inner(cli: Cli) -> Result { strict, timeout, watch, - out, - json, + output, + format, emit_envelope, } => { + let out = output; let issued = parse_issued_at(issued_at, now)?; - if now && !json { + if now && !matches!(format, crate::check::OutputFormat::Json) { // The loud opt-in the flag's help promises (suppressed under - // --json, where stderr carries exactly one problem document - // on failure). + // --format json, where one finite report is the only output). eprintln!( "note: --now resolves issuedAt to {} (wall clock; output is NOT byte-stable)", issued.to_rfc3339() @@ -415,7 +431,7 @@ fn run_inner(cli: Cli) -> Result { format!("cannot write {}: {err}", out.display()), ) })?; - if json { + if matches!(format, crate::check::OutputFormat::Json) { let summary = serde_json::json!({ "pdfPath": out.display().to_string(), "pdfSha256": rendered.pdf_sha256, diff --git a/crates/registry-render/src/init.rs b/crates/registry-render/src/init.rs index 59669a71c9..3456e9b1f2 100644 --- a/crates/registry-render/src/init.rs +++ b/crates/registry-render/src/init.rs @@ -113,7 +113,7 @@ const README: &str = r#"# Render bundle - `fixtures/data.json` — a valid first payload. Workflow: edit → `registry-render compile --bundle . --type letter ---data fixtures/data.json --issued-at … --out letter.pdf` (or `--watch`) → +--data fixtures/data.json --issued-at … --output letter.pdf` (or `--watch`) → `registry-render check` → `registry-render package --bundle . --output ../letter-package`. Point `package.root` at the new package directory before serving. @@ -215,7 +215,7 @@ pub fn scaffold(dir: &Path, labels: &[String]) -> Result { write_new(&dir.join("fixtures/data.json"), FIXTURE_DATA)?; write_new(&dir.join("README.md"), README)?; println!( - "scaffolded {}. First render:\n registry-render compile --bundle {} --type letter --data {} --issued-at 2026-01-01T00:00:00Z --out letter.pdf", + "scaffolded {}. First render:\n registry-render compile --bundle {} --type letter --data {} --issued-at 2026-01-01T00:00:00Z --output letter.pdf", dir.display(), dir.display(), dir.join("fixtures").join("data.json").display() diff --git a/crates/registry-render/src/main.rs b/crates/registry-render/src/main.rs index f2fc7e449c..5320bf0f4c 100644 --- a/crates/registry-render/src/main.rs +++ b/crates/registry-render/src/main.rs @@ -1,8 +1,76 @@ use clap::Parser as _; +fn retired_option(args: &[std::ffi::OsString]) -> Option<&'static str> { + let command = args.get(1)?.to_str()?; + let has_option = |name: &str| { + args.iter() + .skip(2) + .take_while(|arg| arg.as_os_str() != "--") + .any(|argument| { + argument.as_os_str() == name + || argument + .to_str() + .and_then(|argument| argument.strip_prefix(name)) + .is_some_and(|suffix| suffix.starts_with('=')) + }) + }; + match command { + "compile" if has_option("--json") => { + Some("`--json` is no longer accepted; use `--format json` instead") + } + "validate" if has_option("--json") => { + Some("`--json` is no longer accepted; use `--format json` instead") + } + "compile" if has_option("--out") => { + Some("`--out` is no longer accepted; use `--output` instead") + } + _ => None, + } +} + +fn requests_json(args: &[std::ffi::OsString]) -> bool { + let mut args = args + .iter() + .skip(1) + .take_while(|arg| arg.as_os_str() != "--"); + while let Some(argument) = args.next() { + if argument.as_os_str() == "--format" { + return args.next().and_then(|value| value.to_str()) == Some("json"); + } + if argument + .to_str() + .and_then(|value| value.strip_prefix("--format=")) + == Some("json") + { + return true; + } + } + false +} + fn main() { - let cli = registry_render::cli::Cli::parse(); - let code = registry_render::cli::run(cli); + let args = std::env::args_os().collect::>(); + if let Some(message) = retired_option(&args) { + let problem = registry_render::RenderProblem::new( + registry_render::ProblemKind::InvalidArgument, + message, + ); + let code = registry_render::cli::report_problem(problem, requests_json(&args)); + registry_render::cli::flush(); + std::process::exit(code); + } + let format_supplied = args + .iter() + .skip(1) + .take_while(|arg| arg.as_os_str() != "--") + .any(|arg| { + arg.as_os_str() == "--format" + || arg + .to_str() + .is_some_and(|value| value.starts_with("--format=")) + }); + let cli = registry_render::cli::Cli::parse_from(args); + let code = registry_render::cli::run(cli, format_supplied); registry_render::cli::flush(); std::process::exit(code); } diff --git a/crates/registry-render/src/problem.rs b/crates/registry-render/src/problem.rs index f96d932dc7..921bbb3fd3 100644 --- a/crates/registry-render/src/problem.rs +++ b/crates/registry-render/src/problem.rs @@ -6,7 +6,7 @@ use std::fmt; use registry_platform_yaml::{Diagnostic, Report}; /// Base URI for RFC 9457 problem types, shared by HTTP responses and CLI -/// `--json` failure documents. +/// `--format json` failure documents. pub const PROBLEM_TYPE_BASE: &str = "https://render.registrystack.org/problems"; /// The closed set of Render failure kinds. @@ -57,7 +57,7 @@ pub enum ProblemKind { } impl ProblemKind { - /// Stable process exit code for CLI use (`--json` scripts rely on it). + /// Stable process exit code for CLI use (`--format json` scripts rely on it). /// One code per kind: scripts must be able to branch exactly. pub fn exit_code(&self) -> i32 { match self { diff --git a/crates/registry-render/tests/scaffold.rs b/crates/registry-render/tests/scaffold.rs index b0fe336440..e8f9c20483 100644 --- a/crates/registry-render/tests/scaffold.rs +++ b/crates/registry-render/tests/scaffold.rs @@ -32,7 +32,7 @@ fn compile(bundle: &Path) -> Output { bundle.join("fixtures/data.json").to_str().unwrap(), "--issued-at", "2026-01-01T00:00:00Z", - "--out", + "--output", bundle.join("letter.pdf").to_str().unwrap(), ]) } @@ -215,7 +215,7 @@ fn watch_and_emit_envelope_are_refused_together() { dir.join("fixtures/data.json").to_str().unwrap(), "--issued-at", "2026-01-01T00:00:00Z", - "--out", + "--output", dir.join("letter.pdf").to_str().unwrap(), "--watch", "--emit-envelope", @@ -402,7 +402,7 @@ fn package_command_writes_repeatable_verified_envelopes_and_revision() { dir.join("fixtures/data.json").to_str().unwrap(), "--issued-at", "2026-01-01T00:00:00Z", - "--out", + "--output", packages.join("letter.pdf").to_str().unwrap(), ]); assert!( @@ -445,7 +445,7 @@ fn compile_is_bounded_by_a_worker_resource_wall() { dir.join("fixtures/data.json").to_str().unwrap(), "--issued-at", "2026-01-01T00:00:00Z", - "--out", + "--output", dir.join("letter.pdf").to_str().unwrap(), "--timeout", "1", @@ -477,7 +477,7 @@ fn now_is_loud_about_the_resolved_issued_at() { "--data", dir.join("fixtures/data.json").to_str().unwrap(), "--now", - "--out", + "--output", dir.join("letter.pdf").to_str().unwrap(), ]); assert!(out.status.success()); @@ -491,7 +491,7 @@ fn now_is_loud_about_the_resolved_issued_at() { } #[test] -fn json_failures_are_problem_documents() { +fn format_json_compile_failures_are_problem_documents_on_stdout() { let dir = tempdir(); run(&["init", dir.to_str().unwrap()]); let bad = dir.join("bad.json"); @@ -506,16 +506,19 @@ fn json_failures_are_problem_documents() { bad.to_str().unwrap(), "--issued-at", "2026-01-01T00:00:00Z", - "--out", + "--output", dir.join("letter.pdf").to_str().unwrap(), - "--json", + // rstack forwards its report format after the product arguments. + "--format", + "json", ]); assert_eq!( out.status.code(), Some(registry_render::ProblemKind::DataInvalid.exit_code()) ); + assert!(out.stderr.is_empty(), "JSON mode has no stderr chatter"); let problem: serde_json::Value = - serde_json::from_slice(&out.stderr).expect("stderr is one JSON problem document"); + serde_json::from_slice(&out.stdout).expect("stdout is one JSON problem document"); assert_eq!(problem["title"], "data-invalid", "{problem}"); assert_eq!( problem["type"], "https://render.registrystack.org/problems/data-invalid", @@ -530,7 +533,7 @@ fn json_failures_are_problem_documents() { } #[test] -fn validate_supports_json() { +fn validate_supports_format_json() { let dir = tempdir(); run(&["init", dir.to_str().unwrap()]); let good = run(&[ @@ -541,7 +544,8 @@ fn validate_supports_json() { "letter", "--data", dir.join("fixtures/data.json").to_str().unwrap(), - "--json", + "--format", + "json", ]); assert_eq!(good.status.code(), Some(0)); let value: serde_json::Value = serde_json::from_slice(&good.stdout).expect("json stdout"); @@ -557,17 +561,198 @@ fn validate_supports_json() { "letter", "--data", bad_path.to_str().unwrap(), - "--json", + "--format", + "json", ]); assert_eq!( bad.status.code(), Some(registry_render::ProblemKind::DataInvalid.exit_code()) ); + assert!(bad.stderr.is_empty(), "JSON mode has no stderr chatter"); let problem: serde_json::Value = - serde_json::from_slice(&bad.stderr).expect("json problem on stderr"); + serde_json::from_slice(&bad.stdout).expect("json problem on stdout"); assert_eq!(problem["title"], "data-invalid", "{problem}"); } +#[test] +fn compile_supports_format_json() { + let dir = tempdir(); + run(&["init", dir.to_str().unwrap()]); + let pdf = dir.join("letter.pdf"); + let compiled = run(&[ + "compile", + "--bundle", + dir.to_str().unwrap(), + "--type", + "letter", + "--data", + dir.join("fixtures/data.json").to_str().unwrap(), + "--issued-at", + "2026-01-01T00:00:00Z", + "--output", + pdf.to_str().unwrap(), + "--format", + "json", + ]); + assert_eq!(compiled.status.code(), Some(0)); + assert!( + compiled.stderr.is_empty(), + "JSON mode has no stderr chatter" + ); + let summary: serde_json::Value = + serde_json::from_slice(&compiled.stdout).expect("JSON summary on stdout"); + assert_eq!(summary["pdfPath"], pdf.display().to_string()); + assert!(summary["pdfSha256"].is_string()); + assert!(summary["dataSha256"].is_string()); + assert!(pdf.is_file()); +} + +#[test] +fn retired_out_is_refused_with_its_replacement_before_effects() { + let dir = tempdir(); + run(&["init", dir.to_str().unwrap()]); + let pdf = dir.join("retired-out.pdf"); + let compiled = run(&[ + "compile", + "--bundle", + dir.to_str().unwrap(), + "--type", + "letter", + "--data", + dir.join("fixtures/data.json").to_str().unwrap(), + "--issued-at", + "2026-01-01T00:00:00Z", + "--out", + pdf.to_str().unwrap(), + ]); + assert_eq!( + compiled.status.code(), + Some(registry_render::ProblemKind::InvalidArgument.exit_code()) + ); + assert!(!pdf.exists(), "retired syntax writes no PDF"); + let error = String::from_utf8_lossy(&compiled.stderr); + assert!(error.contains("--out"), "{error}"); + assert!(error.contains("--output"), "{error}"); +} + +#[test] +fn retired_out_with_format_json_is_a_problem_document_on_stdout() { + let dir = tempdir(); + run(&["init", dir.to_str().unwrap()]); + let pdf = dir.join("retired-out-json.pdf"); + let compiled = run(&[ + "compile", + "--bundle", + dir.to_str().unwrap(), + "--type", + "letter", + "--data", + dir.join("fixtures/data.json").to_str().unwrap(), + "--issued-at", + "2026-01-01T00:00:00Z", + "--out", + pdf.to_str().unwrap(), + "--format", + "json", + ]); + assert_eq!( + compiled.status.code(), + Some(registry_render::ProblemKind::InvalidArgument.exit_code()) + ); + assert!(!pdf.exists(), "retired syntax writes no PDF"); + assert!( + compiled.stderr.is_empty(), + "JSON mode has no stderr chatter" + ); + let problem: serde_json::Value = + serde_json::from_slice(&compiled.stdout).expect("JSON problem on stdout"); + assert_eq!(problem["title"], "invalid-argument", "{problem}"); + assert!( + problem["detail"] + .as_str() + .is_some_and(|detail| { detail.contains("--out") && detail.contains("--output") }), + "{problem}" + ); +} + +#[test] +fn watch_refuses_an_explicit_report_format_before_effects() { + let dir = tempdir(); + run(&["init", dir.to_str().unwrap()]); + let pdf = dir.join("watch.pdf"); + let watched = run(&[ + "compile", + "--bundle", + dir.to_str().unwrap(), + "--type", + "letter", + "--data", + dir.join("fixtures/data.json").to_str().unwrap(), + "--issued-at", + "2026-01-01T00:00:00Z", + "--watch", + "--output", + pdf.to_str().unwrap(), + "--format", + "human", + ]); + assert_eq!( + watched.status.code(), + Some(registry_render::ProblemKind::InvalidArgument.exit_code()) + ); + assert!(!pdf.exists(), "refused watch writes no PDF"); + let error = String::from_utf8_lossy(&watched.stderr); + assert!(error.contains("--format"), "{error}"); + assert!(error.contains("--watch"), "{error}"); +} + +#[test] +fn retired_json_is_refused_with_its_replacement_before_effects() { + let dir = tempdir(); + run(&["init", dir.to_str().unwrap()]); + let pdf = dir.join("retired-json.pdf"); + let compiled = run(&[ + "compile", + "--bundle", + dir.to_str().unwrap(), + "--type", + "letter", + "--data", + dir.join("fixtures/data.json").to_str().unwrap(), + "--issued-at", + "2026-01-01T00:00:00Z", + "--output", + pdf.to_str().unwrap(), + "--json", + ]); + assert_eq!( + compiled.status.code(), + Some(registry_render::ProblemKind::InvalidArgument.exit_code()) + ); + assert!(!pdf.exists(), "retired syntax writes no PDF"); + let compile_error = String::from_utf8_lossy(&compiled.stderr); + assert!(compile_error.contains("--json"), "{compile_error}"); + assert!(compile_error.contains("--format json"), "{compile_error}"); + + let validated = run(&[ + "validate", + "--bundle", + dir.to_str().unwrap(), + "--type", + "letter", + "--data", + dir.join("fixtures/data.json").to_str().unwrap(), + "--json", + ]); + assert_eq!( + validated.status.code(), + Some(registry_render::ProblemKind::InvalidArgument.exit_code()) + ); + let validate_error = String::from_utf8_lossy(&validated.stderr); + assert!(validate_error.contains("--json"), "{validate_error}"); + assert!(validate_error.contains("--format json"), "{validate_error}"); +} + #[test] fn check_names_a_locale_missing_a_label_key() { let dir = tempdir(); diff --git a/crates/registry-rstack/install.sh b/crates/registry-rstack/install.sh index 3e1da5e02f..9abf240287 100644 --- a/crates/registry-rstack/install.sh +++ b/crates/registry-rstack/install.sh @@ -43,6 +43,7 @@ install_root="" asset_dir="" selected=() select_all=0 +component_delimiter=0 seen_release=0 seen_bin_dir=0 seen_install_root=0 @@ -104,6 +105,7 @@ while [[ "$#" -gt 0 ]]; do exit 0 ;; --) + component_delimiter=1 shift selected+=("$@") break @@ -370,6 +372,9 @@ fi if [[ "${select_all}" -eq 1 ]]; then arguments+=(--all) elif [[ "${#selected[@]}" -gt 0 ]]; then + if [[ "${component_delimiter}" -eq 1 ]]; then + arguments+=(--) + fi arguments+=("${selected[@]}") fi "${launcher}" "${arguments[@]}" diff --git a/crates/registry-rstack/src/cli.rs b/crates/registry-rstack/src/cli.rs index 3099d58932..e9396f2373 100644 --- a/crates/registry-rstack/src/cli.rs +++ b/crates/registry-rstack/src/cli.rs @@ -45,6 +45,14 @@ enum ReportFormat { Json, } +#[derive(Clone, Copy)] +enum RequestedReportFormat { + Human, + Json, + Junit, + Invalid, +} + #[derive(Parser)] #[command( name = "rstack", @@ -53,7 +61,8 @@ enum ReportFormat { about = "Registry Stack command and component manager" )] struct Cli { - /// Report encoding. Defaults to human even when stdout is piped. + /// Report encoding. Defaults to human even when stdout is piped. Evidence + /// test and fixtures run also accept junit. #[arg(long, global = true, value_enum, action = ArgAction::Append, default_value = "human")] format: Vec, @@ -153,10 +162,30 @@ pub fn command() -> clap::Command { pub fn run() -> i32 { let arguments: Vec = std::env::args_os().collect(); - let (duplicate_format, json_requested, format_supplied) = report_arguments(&arguments); + let (duplicate_format, json_requested, format_supplied, requested_format) = + report_arguments(&arguments); if duplicate_format { return emit_failure(2, "--format may be supplied only once", json_requested); } + let evidence_junit = match requested_format { + Some(RequestedReportFormat::Junit) if evidence_supports_junit(&arguments) => true, + Some(RequestedReportFormat::Junit) => { + return emit_failure( + 2, + "--format junit is supported only by evidence test and evidence fixtures run", + json_requested, + ); + } + Some(RequestedReportFormat::Invalid) => { + return emit_failure(2, "--format must be human or json", json_requested); + } + _ => false, + }; + let arguments = if evidence_junit { + remove_report_format(arguments) + } else { + arguments + }; let cli = match Cli::try_parse_from(arguments) { Ok(cli) => cli, Err(error) => { @@ -168,7 +197,7 @@ pub fn run() -> i32 { return code; } }; - if let Err(error) = execute(cli, format_supplied) { + if let Err(error) = execute(cli, format_supplied, evidence_junit) { if error.reported { return error.code; } @@ -177,9 +206,10 @@ pub fn run() -> i32 { 0 } -fn report_arguments(arguments: &[OsString]) -> (bool, bool, bool) { +fn report_arguments(arguments: &[OsString]) -> (bool, bool, bool, Option) { let mut occurrences = 0; let mut json = false; + let mut requested = None; let mut values = arguments.iter().skip(1); while let Some(argument) = values.next() { if argument == "--" { @@ -187,16 +217,93 @@ fn report_arguments(arguments: &[OsString]) -> (bool, bool, bool) { } if argument == "--format" { occurrences += 1; - json |= values.next().is_some_and(|value| value == "json"); + if let Some(value) = values.next() { + requested = Some(requested_report_format(value)); + json |= matches!(requested, Some(RequestedReportFormat::Json)); + } } else if let Some(value) = argument .to_str() .and_then(|text| text.strip_prefix("--format=")) { occurrences += 1; - json |= value == "json"; + requested = Some(requested_report_format(&OsString::from(value))); + json |= matches!(requested, Some(RequestedReportFormat::Json)); } } - (occurrences > 1, json, occurrences > 0) + (occurrences > 1, json, occurrences > 0, requested) +} + +fn requested_report_format(value: &OsString) -> RequestedReportFormat { + match value.to_str() { + Some("human") => RequestedReportFormat::Human, + Some("json") => RequestedReportFormat::Json, + Some("junit") => RequestedReportFormat::Junit, + _ => RequestedReportFormat::Invalid, + } +} + +fn evidence_supports_junit(arguments: &[OsString]) -> bool { + let command = command_tokens(arguments); + matches!( + command.as_slice(), + ["evidence", "test", ..] | ["evidence", "fixtures", "run", ..] + ) +} + +fn command_tokens(arguments: &[OsString]) -> Vec<&str> { + let mut command = Vec::new(); + let mut index = 1; + while index < arguments.len() { + let Some(argument) = arguments[index].to_str() else { + return Vec::new(); + }; + if argument == "--" { + break; + } + if matches!( + argument, + "--format" | "--install-root" | "--bin-dir" | "--asset-dir" + ) { + index += 2; + continue; + } + if argument.starts_with("--format=") + || argument.starts_with("--install-root=") + || argument.starts_with("--bin-dir=") + || argument.starts_with("--asset-dir=") + { + index += 1; + continue; + } + if !argument.starts_with('-') { + command.push(argument); + } + index += 1; + } + command +} + +fn remove_report_format(mut arguments: Vec) -> Vec { + let mut index = 1; + while index < arguments.len() { + if arguments[index] == "--" { + break; + } + if arguments[index] == "--format" { + let end = usize::min(index + 2, arguments.len()); + arguments.drain(index..end); + break; + } + if arguments[index] + .to_str() + .is_some_and(|argument| argument.starts_with("--format=")) + { + arguments.remove(index); + break; + } + index += 1; + } + arguments } fn emit_failure(code: i32, message: &str, json: bool) -> i32 { @@ -269,7 +376,7 @@ impl From<&str> for CliFailure { } } -fn execute(cli: Cli, format_supplied: bool) -> Result<(), CliFailure> { +fn execute(cli: Cli, format_supplied: bool, evidence_junit: bool) -> Result<(), CliFailure> { if cli.format.len() > 1 { return Err("--format may be supplied only once".into()); } @@ -469,7 +576,13 @@ fn execute(cli: Cli, format_supplied: bool) -> Result<(), CliFailure> { if let Some((help_format, path)) = product_help_request(arguments, format)? { product_help(product, help_format, &path)?; } else { - dispatch_product(&cli, product, &arguments[1..], format_supplied)?; + dispatch_product( + &cli, + product, + &arguments[1..], + format_supplied, + evidence_junit, + )?; } } } @@ -907,6 +1020,7 @@ fn dispatch_product( product: &str, arguments: &[OsString], format_supplied: bool, + evidence_junit: bool, ) -> Result<(), CliFailure> { let Some((lease, selection, manifest)) = selected_state(cli)? else { return Err(CliFailure::domain(format!( @@ -929,28 +1043,27 @@ fn dispatch_product( .join(&component.entrypoint); let mut command = ProcessCommand::new(&executable); set_selected_generation_environment(&mut command, lease.path(), &selection, &manifest)?; - let product_has_format = arguments + let delimiter = arguments .iter() - .take_while(|argument| *argument != "--") - .any(|argument| { - argument == "--format" - || argument - .to_str() - .is_some_and(|value| value.starts_with("--format=")) - }); - if format_supplied && !product_has_format { - let format = cli - .format - .first() - .expect("explicit report format is parsed"); + .position(|argument| argument == "--") + .unwrap_or(arguments.len()); + let product_has_format = arguments.iter().take(delimiter).any(|argument| { + argument == "--format" + || argument + .to_str() + .is_some_and(|value| value.starts_with("--format=")) + }); + let format = cli.format.first().copied().unwrap_or(ReportFormat::Human); + if evidence_junit { + debug_assert_eq!(product, "evidence"); + command.args(&arguments[..delimiter]); + command.args(["--format", "junit"]); + command.args(&arguments[delimiter..]); + } else if format_supplied && !product_has_format { let format = match format { ReportFormat::Human => "human", ReportFormat::Json => "json", }; - let delimiter = arguments - .iter() - .position(|argument| argument == "--") - .unwrap_or(arguments.len()); command.args(&arguments[..delimiter]); command.args(["--format", format]); command.args(&arguments[delimiter..]); diff --git a/crates/registry-rstack/tests/cli.rs b/crates/registry-rstack/tests/cli.rs index c049881cfa..c915aa9a76 100644 --- a/crates/registry-rstack/tests/cli.rs +++ b/crates/registry-rstack/tests/cli.rs @@ -120,6 +120,23 @@ fn action_help_accepts_json_after_the_product_without_an_installation() { assert_eq!(report["command"]["name"], "check"); } +#[test] +fn evidence_junit_help_remains_offline_for_supported_commands() { + for arguments in [ + &["--format", "junit", "evidence", "test", "--help"][..], + &["evidence", "fixtures", "run", "--help", "--format=junit"][..], + ] { + let output = run(arguments); + assert!(output.status.success(), "{arguments:?}: {output:?}"); + assert!(output.stderr.is_empty(), "{arguments:?}: {output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!( + stdout.contains("rstack evidence"), + "{arguments:?}: {stdout}" + ); + } +} + #[test] fn offline_help_refuses_unknown_surplus_actions_and_options() { for arguments in [ diff --git a/crates/registry-rstack/tests/dispatch.rs b/crates/registry-rstack/tests/dispatch.rs index a7e62cc65a..16bf2e6179 100644 --- a/crates/registry-rstack/tests/dispatch.rs +++ b/crates/registry-rstack/tests/dispatch.rs @@ -104,6 +104,93 @@ fn fixture(directory: &Path) -> (PathBuf, PathBuf, PathBuf) { (manifest_path, checksums, launcher) } +fn install_products(directory: &Path, products: &[&str]) -> (PathBuf, PathBuf) { + let assets = directory.join("assets"); + fs::create_dir(&assets).unwrap(); + let (manifest, checksums, launcher) = fixture(&assets); + let root = directory.join("lib"); + let bin = directory.join("bin"); + let installed = Command::new(env!("CARGO_BIN_EXE_rstack")) + .args(["bootstrap", "--release-manifest"]) + .arg(manifest) + .arg("--checksum-file") + .arg(checksums) + .arg("--launcher-asset") + .arg(launcher) + .arg("--asset-dir") + .arg(&assets) + .arg("--install-root") + .arg(&root) + .arg("--bin-dir") + .arg(&bin) + .args(products) + .output() + .unwrap(); + assert!(installed.status.success(), "{installed:?}"); + (root, bin) +} + +fn installed_command(bin: &Path, root: &Path) -> Command { + let mut command = Command::new(bin.join("rstack")); + command + .arg("--install-root") + .arg(root) + .arg("--bin-dir") + .arg(bin); + command +} + +#[test] +fn evidence_junit_format_reaches_only_the_supported_commands() { + let scratch = tempfile::tempdir().unwrap(); + let (root, bin) = install_products(scratch.path(), &["evidence"]); + + for arguments in [ + &["--format", "junit", "evidence", "test", "project"][..], + &["evidence", "test", "project", "--format", "junit"][..], + &["evidence", "fixtures", "run", "project", "--format=junit"][..], + ] { + let output = installed_command(&bin, &root) + .args(arguments) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(7), "{arguments:?}: {output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("arg:--format\narg:junit\n"), "{stdout}"); + assert!(!stdout.contains("arg:human\n"), "{stdout}"); + } + + let unsupported = installed_command(&bin, &root) + .args(["evidence", "check", "project", "--format", "junit"]) + .output() + .unwrap(); + assert_eq!(unsupported.status.code(), Some(2), "{unsupported:?}"); + assert!(unsupported.stdout.is_empty(), "{unsupported:?}"); + + let management = installed_command(&bin, &root) + .args(["components", "list", "--format", "junit"]) + .output() + .unwrap(); + assert_eq!(management.status.code(), Some(2), "{management:?}"); + assert!(management.stdout.is_empty(), "{management:?}"); + + let other_product = installed_command(&bin, &root) + .args(["render", "validate", "--format", "junit"]) + .output() + .unwrap(); + assert_eq!(other_product.status.code(), Some(2), "{other_product:?}"); + assert!(other_product.stdout.is_empty(), "{other_product:?}"); + + let duplicate = installed_command(&bin, &root) + .args([ + "--format", "human", "evidence", "test", "project", "--format", "junit", + ]) + .output() + .unwrap(); + assert_eq!(duplicate.status.code(), Some(2), "{duplicate:?}"); + assert!(duplicate.stdout.is_empty(), "{duplicate:?}"); +} + #[test] fn old_invocation_keeps_its_generation_while_new_one_activates() { let scratch = tempfile::tempdir().unwrap(); diff --git a/crates/registry-rstack/tests/install_script.rs b/crates/registry-rstack/tests/install_script.rs index ff5ba43029..26e4e88e39 100644 --- a/crates/registry-rstack/tests/install_script.rs +++ b/crates/registry-rstack/tests/install_script.rs @@ -52,6 +52,45 @@ fn explicit_install_paths_do_not_require_home() { ); } +#[test] +fn component_delimiter_is_forwarded_before_an_option_shaped_component() { + let fixture = InstallerFixture::new(); + let mut command = fixture.command(); + command.args(["--", "--all"]); + let output = command.output().unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let arguments = fs::read_to_string(&fixture.arguments).unwrap(); + assert!( + arguments.ends_with("--\n--all\n"), + "launcher arguments: {arguments}" + ); +} + +#[test] +fn ordinary_component_selection_is_forwarded_without_a_delimiter() { + let fixture = InstallerFixture::new(); + let mut command = fixture.command(); + command.arg("breg"); + let output = command.output().unwrap(); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let arguments = fs::read_to_string(&fixture.arguments).unwrap(); + assert!( + arguments.ends_with("breg\n"), + "launcher arguments: {arguments}" + ); + assert!(!arguments.ends_with("--\nbreg\n")); +} + struct InstallerFixture { _temp: TempDir, assets: PathBuf, diff --git a/docs/site/src/content/docs/tutorials/first-render-document.mdx b/docs/site/src/content/docs/tutorials/first-render-document.mdx index baeaf65a3f..0c506af985 100644 --- a/docs/site/src/content/docs/tutorials/first-render-document.mdx +++ b/docs/site/src/content/docs/tutorials/first-render-document.mdx @@ -70,7 +70,7 @@ registry-render compile \ --type letter \ --data render-work/bundle/fixtures/data.json \ --issued-at 2026-09-16T10:32:00Z \ - --out render-work/letter.pdf + --output render-work/letter.pdf ``` ```text test-expect diff --git a/products/render/DEFINITION-OF-DONE.md b/products/render/DEFINITION-OF-DONE.md index 42f501c12d..bfd4aa36b3 100644 --- a/products/render/DEFINITION-OF-DONE.md +++ b/products/render/DEFINITION-OF-DONE.md @@ -66,7 +66,7 @@ mechanisms (small fixed page, QR) and is not a fourth project. | Assets | Request assets are base64, media-type checked (jpeg/png by magic bytes), and capped at code level (per-asset 2 MiB, per-request 8 MiB — a reviewed constant, not per-document schema declaration); decoded by the renderer and exposed only as virtual `assets/` files; `dataSha256` covers their exact bytes. Oversized or wrong-type assets fail validation with a data-path pointer. (Wording amended 2026-09-17 to match the implementation.) | | Path safety | World-enforced: `read`/`image`/package resolution reject `..` and absolute paths, then require an exact key in the immutable bundle snapshot or the request-local `assets/` namespace. Symlinks are refused while packaging and loading. Negative tests cover traversal, absolute paths, symlinks, virtual-namespace escape, and deterministic path replacement after shared verification. | | Resource enforcement | `serve` renders in a supervised worker process: killed at the configured timeout, memory-capped by rlimit, recycled after a panic (`catch_unwind` in-process backstop → 500 problem + audit), bounded concurrency (documented max, CPU-capped pool), graceful shutdown draining in-flight renders. A pathological-template test proves kill → problem → audit, repeatedly, without degrading the service. CLI uses bounded worker threads with the same timeout semantics. | -| Validation and errors | All failure classes — schema violations (with JSON pointers), package drift (with the offending file), package pin mismatch (with the common expected/found shape), compile errors (file/line), missing labels, unvendored imports, timeout, panic, oversize output — are RFC 9457 problem documents on HTTP and typed exit codes under CLI `--json`. No stack trace or raw Typst diagnostic escapes to a caller. `registry-render validate` dry-runs data against the schema without rendering. | +| Validation and errors | All failure classes — schema violations (with JSON pointers), package drift (with the offending file), package pin mismatch (with the common expected/found shape), compile errors (file/line), missing labels, unvendored imports, timeout, panic, oversize output — are RFC 9457 problem documents on HTTP and typed exit codes under CLI `--format json`, which writes one finite object on stdout. No stack trace or raw Typst diagnostic escapes to a caller. `registry-render validate` dry-runs data against the schema without rendering. | | HTTP contract | `GET /v1/documents`; `POST /v1/render/{type}` with required `issuedAt`; `Accept: application/pdf`/`*/*`/absent returns PDF bytes + `X-Registry-Pdf-Sha256`/`X-Registry-Data-Sha256`/`X-Registry-Document-Version`; `Accept: application/json` returns `{pdfBase64, pdfSha256, dataSha256, documentVersion}`. `Idempotency-Key` is an opaque correlation id: echoed and audited, never dedupe. `/health`, `/ready` (audit readiness included), static `/openapi.json`. Listener defaults to loopback/private; no TLS code. | | Authentication | API key from a secret file, ≥32 bytes, constant-time compare (authcommon); malformed and missing keys get indistinguishable `401` problems; `401`s are audited. No OIDC in v1. | | Audit | The shared platform audit writer, `file` (owner-only, `fsync`ed, single-writer, rotated and retained) or `stdout`, one envelope per line with schema `render.registrystack.org/audit/v1`. A render writes a `request` entry accepted **before** the worker starts and a `response` entry accepted **before** the response, both failing closed; refusals before any render (validation, 401, 413) are one `response` entry; failures after the render starts (timeout, panic, drift) are the `response` entry; the pair shares one `correlation`; events carry type/version/bundle/renderer+Typst pin/hashes/caller/trace/correlation and no data values or asset bytes. `registry-render check --require-audit-under` proves a `file` destination resolves under a persistent root and refuses `stdout`. | diff --git a/products/render/README.md b/products/render/README.md index 765018eb66..50adf3995d 100644 --- a/products/render/README.md +++ b/products/render/README.md @@ -13,7 +13,7 @@ Render is a **pure function**: data in, PDF out. One secret file in serve mode (the caller API key), no database, no outbound calls. It runs three ways with the same guarantees: -- **CLI** — `registry-render compile --bundle … --data … --issued-at … --out …`, +- **CLI** — `registry-render compile --bundle … --data … --issued-at … --output …`, offline, zero config: branch printing, template authoring, CI. - **Service** — `registry-render serve`: one POST endpoint (`/v1/render/{type}`), API-key auth, supervised worker processes, and value-free audit entries @@ -27,7 +27,7 @@ with the same guarantees: cargo run -p registry-render -- init ./my-bundle # write data.json with {reference, body, footer-note} cargo run -p registry-render -- compile --bundle ./my-bundle --type letter \ - --data data.json --issued-at 2026-01-01T00:00:00Z --out letter.pdf + --data data.json --issued-at 2026-01-01T00:00:00Z --output letter.pdf cargo run -p registry-render -- package --bundle ./my-bundle \ --output ./my-package --revision "$(git rev-parse HEAD)" ``` @@ -58,8 +58,8 @@ Latin font set. Scripts beyond Latin (Arabic, Hebrew, …) need a bundle font ## Exit codes Every CLI failure is one closed vocabulary (`ProblemKind`), and each kind -has one stable exit code — scripts branch exactly. With `--json`, the same -failure prints an RFC 9457 problem document on stderr. 0 is success; the +has one stable exit code — scripts branch exactly. With `--format json`, the same +failure prints one RFC 9457 problem document on stdout. 0 is success; the failure codes are 2-22 (clap's own usage errors also exit 2, and a panic exits 101): From 3ad9a8e703b632f8da080833242075f2cf853ed6 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Fri, 23 Oct 2026 04:42:14 +0700 Subject: [PATCH 9/9] fix: retry transient staged executable contention Signed-off-by: Jeremi Joslin --- crates/registry-rstack/src/install.rs | 63 +++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/crates/registry-rstack/src/install.rs b/crates/registry-rstack/src/install.rs index 303191beed..de8fc13634 100644 --- a/crates/registry-rstack/src/install.rs +++ b/crates/registry-rstack/src/install.rs @@ -8,13 +8,17 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; use std::fmt; use std::fs; -use std::io::Read; +use std::io::{self, Read}; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; +use std::thread; +use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; const MAX_METADATA_BYTES: u64 = 16 * 1024 * 1024; +const EXECUTABLE_BUSY_RETRIES: usize = 5; +const EXECUTABLE_BUSY_RETRY_DELAY: Duration = Duration::from_millis(20); pub struct BootstrapOptions { pub release_manifest: PathBuf, @@ -1099,9 +1103,7 @@ fn extract_macos_archive(archive: &Path, destination: &Path, binary: &str) -> Re } fn smoke_binary(path: &Path, binary: &str, expected_version: &str) -> Result<(), String> { - let output = Command::new(path) - .arg("--version") - .output() + let output = retry_executable_busy(|| Command::new(path).arg("--version").output()) .map_err(|error| format!("cannot start staged {binary}: {error}"))?; let expected = format!("{binary} {expected_version}\n"); if !output.status.success() || output.stdout != expected.as_bytes() { @@ -1109,3 +1111,56 @@ fn smoke_binary(path: &Path, binary: &str, expected_version: &str) -> Result<(), } Ok(()) } + +fn retry_executable_busy(mut operation: impl FnMut() -> io::Result) -> io::Result { + let mut retries = 0; + loop { + match operation() { + Err(error) + if error.kind() == io::ErrorKind::ExecutableFileBusy + && retries < EXECUTABLE_BUSY_RETRIES => + { + retries += 1; + thread::sleep(EXECUTABLE_BUSY_RETRY_DELAY); + } + result => return result, + } + } +} + +#[cfg(test)] +mod tests { + use super::{retry_executable_busy, EXECUTABLE_BUSY_RETRIES}; + use std::io; + + #[test] + fn staged_binary_smoke_recovers_from_transient_executable_busy() { + let mut attempts = 0; + let result = retry_executable_busy(|| { + attempts += 1; + if attempts < 3 { + Err(io::ErrorKind::ExecutableFileBusy.into()) + } else { + Ok(()) + } + }); + + assert!(result.is_ok(), "{result:?}"); + assert_eq!(attempts, 3); + } + + #[test] + fn staged_binary_smoke_stops_retrying_permanent_executable_busy() { + let mut attempts = 0; + let result: io::Result<()> = retry_executable_busy(|| { + attempts += 1; + Err(io::ErrorKind::ExecutableFileBusy.into()) + }); + + assert_eq!(attempts, EXECUTABLE_BUSY_RETRIES + 1); + assert_eq!( + result.unwrap_err().kind(), + io::ErrorKind::ExecutableFileBusy + ); + } +}