From 86382816074d4715baf30c67b9d64a3628ac7844 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 20:11:18 +0700 Subject: [PATCH 1/9] feat(relay): add Rust Python and Node clients Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 38 +- .github/scripts/test_ci_changes.py | 79 +- .github/workflows/ci.yml | 70 +- .github/workflows/release-candidate.yml | 175 +- Cargo.lock | 65 +- Cargo.toml | 10 +- crates/registry-evidence-client/Cargo.toml | 3 +- crates/registry-evidence-client/src/client.rs | 54 +- crates/registry-evidence-client/src/error.rs | 40 +- .../registry-evidence-client/src/outbound.rs | 152 +- .../src/private_key_jwt.rs | 1718 +------------- .../registry-evidence-client/src/problem.rs | 79 +- crates/registry-evidence-client/src/token.rs | 380 +--- crates/registry-evidence/Cargo.toml | 2 +- crates/registry-platform-httpsec/Cargo.toml | 14 +- crates/registry-platform-httpsec/README.md | 7 +- .../registry-platform-httpsec/src/client.rs | 219 ++ crates/registry-platform-httpsec/src/lib.rs | 992 +------- .../registry-platform-httpsec/src/server.rs | 957 ++++++++ .../tests/integration.rs | 2 + crates/registry-platform-httputil/Cargo.toml | 7 + crates/registry-platform-httputil/README.md | 15 +- .../src/client/mod.rs | 181 ++ .../src/client/outbound.rs | 121 + .../src/client/private_key_jwt.rs | 1770 ++++++++++++++ .../src/client/token.rs | 395 ++++ .../src/destination.rs | 28 +- crates/registry-platform-httputil/src/lib.rs | 254 ++- crates/registry-platform-testing/Cargo.toml | 2 +- crates/registry-relay-client-node/.gitignore | 3 + crates/registry-relay-client-node/Cargo.toml | 29 + crates/registry-relay-client-node/LICENSE | 201 ++ crates/registry-relay-client-node/README.md | 54 + .../__test__/binding.test.js | 331 +++ .../__test__/drift.test.js | 58 + crates/registry-relay-client-node/build.rs | 3 + crates/registry-relay-client-node/client.d.ts | 246 ++ crates/registry-relay-client-node/client.js | 171 ++ crates/registry-relay-client-node/index.d.ts | 58 + crates/registry-relay-client-node/index.js | 703 ++++++ .../package-lock.json | 2024 +++++++++++++++++ .../registry-relay-client-node/package.json | 25 + crates/registry-relay-client-node/src/lib.rs | 1241 ++++++++++ crates/registry-relay-client-py/.gitignore | 4 + crates/registry-relay-client-py/Cargo.toml | 35 + crates/registry-relay-client-py/LICENSE | 201 ++ crates/registry-relay-client-py/README.md | 57 + crates/registry-relay-client-py/build.rs | 9 + .../registry-relay-client-py/pyproject.toml | 16 + .../python/registry_relay_client/__init__.py | 5 + .../python/registry_relay_client/__init__.pyi | 228 ++ .../python/registry_relay_client/py.typed | 1 + .../registry-relay-client-py/src/convert.rs | 699 ++++++ crates/registry-relay-client-py/src/lib.rs | 857 +++++++ .../tests/python/bootstrap.py | 51 + .../tests/python/fixtures/token-ca.pem | 20 + .../tests/python/relay_server.py | 169 ++ .../tests/python/test_construction.py | 177 ++ .../tests/python/test_drift.py | 131 ++ .../tests/python/test_errors.py | 99 + .../tests/python/test_gil.py | 49 + .../tests/python/test_happy_path.py | 108 + .../tests/python/test_pagination.py | 146 ++ .../tests/python/test_raw_body.py | 58 + crates/registry-relay-client/Cargo.toml | 28 + crates/registry-relay-client/README.md | 146 ++ crates/registry-relay-client/src/client.rs | 823 +++++++ crates/registry-relay-client/src/config.rs | 144 ++ crates/registry-relay-client/src/error.rs | 136 ++ crates/registry-relay-client/src/lib.rs | 26 + crates/registry-relay-client/src/model.rs | 368 +++ crates/registry-relay-client/src/query.rs | 674 ++++++ crates/registry-relay-client/src/response.rs | 339 +++ crates/registry-relay-client/src/transport.rs | 194 ++ .../tests/contract_parity.rs | 33 + .../tests/http_boundary.rs | 651 ++++++ .../registry-relay-http-contract/Cargo.toml | 12 + .../registry-relay-http-contract/src/lib.rs | 232 ++ crates/registry-relay-v2/Cargo.toml | 4 +- .../examples/problem-catalog.rs | 2 +- crates/registry-relay-v2/src/api.rs | 2 +- crates/registry-relay-v2/src/artifacts.rs | 26 +- crates/registry-relay-v2/src/problem.rs | 110 +- crates/registry-relay-v2/src/sdmx_http.rs | 2 +- crates/registry-relay-v2/src/server.rs | 90 +- .../tests/acceptance_http.rs | 435 ++++ crates/registry-relay/Cargo.toml | 2 +- .../identifiers/contracts/catalog-source.json | 2 +- .../identifiers/generated/catalog.v1.json | 106 +- products/relay-v2/DEFINITION-OF-DONE.md | 3 + products/relay-v2/IMPLEMENTATION.md | 16 + products/relay-v2/README.md | 7 + .../contracts/security-invariant-matrix.yaml | 31 + .../relay-v2/scripts/check-client-contract.sh | 13 + products/relay-v2/scripts/check-contracts.sh | 1 + .../scripts/check-source-neutrality.sh | 41 +- products/relay-v2/scripts/validate_product.py | 3 + release/scripts/check-gates-inventory.py | 9 + release/scripts/check-release-source-model.sh | 4 + release/scripts/registry-release | 5 + release/scripts/release_candidate.py | 11 + release/scripts/smoke-relay-client-package.js | 16 + release/scripts/smoke-relay-client-package.py | 23 + release/scripts/test_check_gates_inventory.py | 22 + .../test_check_release_source_model.py | 4 + release/scripts/test_registry_release.py | 34 +- .../scripts/test_registry_release_plans.py | 3 + release/scripts/test_release_candidate.py | 20 + .../test_release_workflow_structure.py | 24 +- 109 files changed, 17243 insertions(+), 3730 deletions(-) create mode 100644 crates/registry-platform-httpsec/src/client.rs create mode 100644 crates/registry-platform-httpsec/src/server.rs create mode 100644 crates/registry-platform-httputil/src/client/mod.rs create mode 100644 crates/registry-platform-httputil/src/client/outbound.rs create mode 100644 crates/registry-platform-httputil/src/client/private_key_jwt.rs create mode 100644 crates/registry-platform-httputil/src/client/token.rs create mode 100644 crates/registry-relay-client-node/.gitignore create mode 100644 crates/registry-relay-client-node/Cargo.toml create mode 100644 crates/registry-relay-client-node/LICENSE create mode 100644 crates/registry-relay-client-node/README.md create mode 100644 crates/registry-relay-client-node/__test__/binding.test.js create mode 100644 crates/registry-relay-client-node/__test__/drift.test.js create mode 100644 crates/registry-relay-client-node/build.rs create mode 100644 crates/registry-relay-client-node/client.d.ts create mode 100644 crates/registry-relay-client-node/client.js create mode 100644 crates/registry-relay-client-node/index.d.ts create mode 100644 crates/registry-relay-client-node/index.js create mode 100644 crates/registry-relay-client-node/package-lock.json create mode 100644 crates/registry-relay-client-node/package.json create mode 100644 crates/registry-relay-client-node/src/lib.rs create mode 100644 crates/registry-relay-client-py/.gitignore create mode 100644 crates/registry-relay-client-py/Cargo.toml create mode 100644 crates/registry-relay-client-py/LICENSE create mode 100644 crates/registry-relay-client-py/README.md create mode 100644 crates/registry-relay-client-py/build.rs create mode 100644 crates/registry-relay-client-py/pyproject.toml create mode 100644 crates/registry-relay-client-py/python/registry_relay_client/__init__.py create mode 100644 crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi create mode 100644 crates/registry-relay-client-py/python/registry_relay_client/py.typed create mode 100644 crates/registry-relay-client-py/src/convert.rs create mode 100644 crates/registry-relay-client-py/src/lib.rs create mode 100644 crates/registry-relay-client-py/tests/python/bootstrap.py create mode 100644 crates/registry-relay-client-py/tests/python/fixtures/token-ca.pem create mode 100644 crates/registry-relay-client-py/tests/python/relay_server.py create mode 100644 crates/registry-relay-client-py/tests/python/test_construction.py create mode 100644 crates/registry-relay-client-py/tests/python/test_drift.py create mode 100644 crates/registry-relay-client-py/tests/python/test_errors.py create mode 100644 crates/registry-relay-client-py/tests/python/test_gil.py create mode 100644 crates/registry-relay-client-py/tests/python/test_happy_path.py create mode 100644 crates/registry-relay-client-py/tests/python/test_pagination.py create mode 100644 crates/registry-relay-client-py/tests/python/test_raw_body.py create mode 100644 crates/registry-relay-client/Cargo.toml create mode 100644 crates/registry-relay-client/README.md create mode 100644 crates/registry-relay-client/src/client.rs create mode 100644 crates/registry-relay-client/src/config.rs create mode 100644 crates/registry-relay-client/src/error.rs create mode 100644 crates/registry-relay-client/src/lib.rs create mode 100644 crates/registry-relay-client/src/model.rs create mode 100644 crates/registry-relay-client/src/query.rs create mode 100644 crates/registry-relay-client/src/response.rs create mode 100644 crates/registry-relay-client/src/transport.rs create mode 100644 crates/registry-relay-client/tests/contract_parity.rs create mode 100644 crates/registry-relay-client/tests/http_boundary.rs create mode 100644 crates/registry-relay-http-contract/Cargo.toml create mode 100644 crates/registry-relay-http-contract/src/lib.rs create mode 100755 products/relay-v2/scripts/check-client-contract.sh create mode 100755 release/scripts/smoke-relay-client-package.js create mode 100755 release/scripts/smoke-relay-client-package.py diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index ff9715018..e2d9a96c7 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -33,6 +33,12 @@ "registry-manifest-core", ), "relay": ("registry-relay",), + "relay-client": ( + "registry-relay-http-contract", + "registry-relay-client", + "registry-relay-client-node", + "registry-relay-client-py", + ), "relay-v2": ("registry-relay-v2", "registry-relayctl"), "evidence": ( "registry-evidence", @@ -56,6 +62,7 @@ PLATFORM_PACKAGES = frozenset(SHARDS["platform"]) MANIFEST_PACKAGES = frozenset(SHARDS["manifest"]) RELAY_V2_PACKAGES = frozenset(SHARDS["relay-v2"]) +RELAY_CLIENT_PACKAGES = frozenset(SHARDS["relay-client"]) # Every input the Evidence tutorial gate replays or is built from. The tutorial # pages and helper scripts here must stay in step with the gate's own registry @@ -127,6 +134,10 @@ EVIDENCE_BINDING_PACKAGES = frozenset( {"registry-evidence-client-node", "registry-evidence-client-py"} ) +RELAY_BINDING_PACKAGES = frozenset( + {"registry-relay-client-node", "registry-relay-client-py"} +) +NATIVE_BINDING_PACKAGES = EVIDENCE_BINDING_PACKAGES | RELAY_BINDING_PACKAGES # A package is exempt from the tutorial trigger only while no tutorial runs it. # The Python binding is what `request-evidence-from-an-application` imports, so @@ -263,6 +274,7 @@ def __init__(self, metadata: dict[str, Any]) -> None: ).as_posix() reverse_dependencies: dict[str, set[str]] = defaultdict(set) + dev_reverse_dependencies: dict[str, set[str]] = defaultdict(set) for package_name, package in packages.items(): for dependency in package["dependencies"]: dependency_name = dependency["name"] @@ -273,8 +285,12 @@ def __init__(self, metadata: dict[str, Any]) -> None: and Path(dependency_path).resolve() == Path(packages[dependency_name]["manifest_path"]).resolve().parent ): - reverse_dependencies[dependency_name].add(package_name) + if dependency.get("kind") == "dev": + dev_reverse_dependencies[dependency_name].add(package_name) + else: + reverse_dependencies[dependency_name].add(package_name) self.reverse_dependencies = reverse_dependencies + self.dev_reverse_dependencies = dev_reverse_dependencies def package_for_path(self, path: str) -> str | None: matches = [ @@ -288,13 +304,19 @@ def package_for_path(self, path: str) -> str | None: def affected_packages(self, seeds: Iterable[str]) -> set[str]: affected = set(seeds) - queue = deque(affected) + propagating = set(seeds) + queue = deque(propagating) while queue: dependency = queue.popleft() for dependent in self.reverse_dependencies.get(dependency, ()): - if dependent not in affected: + if dependent not in propagating: affected.add(dependent) + propagating.add(dependent) queue.append(dependent) + # A dev-dependency must schedule the immediate consumer's tests, + # but it is not linked into that consumer's library. Do not let + # this test-only edge fan out through the consumer's dependents. + affected.update(self.dev_reverse_dependencies.get(dependency, ())) return affected @@ -478,11 +500,10 @@ def classify( or any(path.startswith("editors/") for path in paths) or "registry-language-server" in affected ) - # Reverse dependents, not changed paths: both bindings are Cargo path - # dependents of the SDK and the verifier, so a change to either can move - # the native surface or the error envelope the packages wrap without - # touching a file inside a binding crate. - client_bindings = complete or bool(affected & EVIDENCE_BINDING_PACKAGES) + # Reverse dependents, not changed paths: bindings are Cargo path dependents + # of each SDK, so an SDK or shared HTTP-contract change can move a native + # surface without touching a binding crate. + client_bindings = complete or bool(affected & NATIVE_BINDING_PACKAGES) evidence_tutorial = ( complete @@ -510,6 +531,7 @@ def classify( "platform_hygiene": platform_hygiene, "relay_contracts": "registry-relay" in affected, "relay_v2_contracts": bool(affected & RELAY_V2_PACKAGES), + "relay_client_contracts": bool(affected & RELAY_CLIENT_PACKAGES), "evidence_contracts": bool(affected & EVIDENCE_PACKAGES), "project_authoring": "registryctl" in affected, "release_tool": release_tool, diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 066254719..72ca78253 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -91,13 +91,10 @@ def normal_dependency_metadata(metadata: dict[str, Any]) -> dict[str, Any]: Cargo reports normal, build and dev dependencies in one list per package and tells them apart with a `kind` of null, "build" or "dev". The - classifier keeps all three on purpose, because a dev-dependency edge is - still a reason to run the dependent's tests. That makes its closure the - wrong witness for a claim about what a shipped binary contains: it cannot - see the difference between a crate an editor session compiles in and a - crate only a test harness pulls in. A test that has to prove the stronger - claim classifies against this reduced workspace as well, so a link moved - out of `[dependencies]` fails it however many test-only edges survive. + classifier schedules a direct dev-dependent's tests without propagating + through it, while this reduced workspace proves claims about code a + shipped binary actually links. A link moved out of `[dependencies]` must + therefore fail the stronger routing claim even if test-only edges remain. """ packages = [ @@ -243,7 +240,7 @@ def test_identifier_exporters_and_indirect_inputs_select_the_catalog_gate( "crates/registry-relay-v2/examples/problem-catalog.rs", "crates/registry-relay-v2/src/artifacts.rs", "crates/registry-relay-v2/src/audit.rs", - "crates/registry-relay-v2/src/problem.rs", + "crates/registry-relay-http-contract/src/lib.rs", ): with self.subTest(path=path): self.assertTrue(classify(self.workspace, (path,))["identifiers"]) @@ -393,6 +390,24 @@ def test_reverse_dependencies_are_included(self) -> None: self.assertIn("registry-platform-crypto", outputs["rust_packages"]) self.assertIn("registry-relay", outputs["rust_packages"]) + def test_platform_changes_select_relay_client_reverse_dependents(self) -> None: + # The Relay SDK deliberately reuses the shared bounded outbound and + # OAuth primitives. A platform change can therefore alter its wire + # behavior without touching the SDK source, and must retain the native + # bindings in its affected closure. + outputs = classify( + self.workspace, + ("crates/registry-platform-httputil/src/lib.rs",), + ) + for package in ( + "registry-relay-client", + "registry-relay-client-node", + "registry-relay-client-py", + ): + with self.subTest(package=package): + self.assertIn(package, outputs["rust_packages"]) + self.assertTrue(outputs["client_bindings"]) + def test_ci_workflow_change_runs_the_complete_matrix(self) -> None: outputs = classify(self.workspace, (".github/workflows/ci.yml",)) self.assertCountEqual(outputs["rust_packages"], self.workspace.package_names) @@ -467,13 +482,9 @@ def test_an_authoring_form_change_runs_the_editor_tooling_that_reads_it(self) -> self.assertIn("registry-language-server", outputs["rust_packages"]) self.assertIn("registryctl", outputs["rust_packages"]) - # The language server also dev-depends on the authoring form, for the - # testing feature its own suite drives, and the classifier's closure - # reads every dependency table alike. The assertions above therefore - # hold on that test-only edge by itself, which is a weaker fact than - # the one this test is named for: a test-only edge puts nothing inside - # an adopter's editor. Repeating the closure over normal edges alone - # ties the shards to the link the editor actually compiles against. + # The language server also dev-depends on the authoring form for its + # own test suite. Repeating the closure over normal edges alone ties + # the editor routing claim to the link the editor actually compiles. strict = classify( Workspace(normal_dependency_metadata(self.metadata)), AUTHORING_FORM_CHANGE, @@ -527,11 +538,8 @@ def test_a_test_only_editor_edge_does_not_satisfy_the_authoring_routing( # The check above is only worth its name if it can tell the two edges # apart, so hold it against the workspace where it must not hold: the # language server keeps the test-only dependency and loses the one it - # compiles against. Both halves matter here. The kind-blind closure - # still reaches every editor shard, which is the reason the routing - # claim cannot rest on it, and the normal-edge closure stops at the - # authoring form's own shard, which is the power the routing claim - # borrows from it. + # compiles against. A dev edge still selects that direct test suite, + # but cannot make registryctl a downstream affected package. mutated = dev_only_dependency_metadata( self.metadata, consumer="registry-language-server", @@ -540,7 +548,7 @@ def test_a_test_only_editor_edge_does_not_satisfy_the_authoring_routing( blind = classify(Workspace(mutated), AUTHORING_FORM_CHANGE) self.assertIn("registry-language-server", blind["rust_packages"]) - self.assertIn("registryctl", blind["rust_packages"]) + self.assertNotIn("registryctl", blind["rust_packages"]) strict = classify( Workspace(normal_dependency_metadata(mutated)), @@ -581,6 +589,30 @@ def test_binding_only_change_runs_contracts_but_not_the_tutorial_job(self) -> No {"evidence"}, ) + def test_relay_client_change_runs_its_contract_and_native_binding_gates(self) -> None: + outputs = classify( + self.workspace, + ("crates/registry-relay-client/src/lib.rs",), + ) + self.assertTrue(outputs["relay_client_contracts"]) + self.assertTrue(outputs["client_bindings"]) + # Relay V2 owns the real-router acceptance test and therefore + # dev-depends on the SDK. Its test suite must still run, but the + # dev-only edge cannot cascade into Relay V2's normal dependents. + self.assertIn("registry-relay-v2", outputs["rust_packages"]) + self.assertNotIn("registry-relayctl", outputs["rust_packages"]) + self.assertFalse(outputs["evidence_contracts"]) + self.assertEqual( + {entry["name"] for entry in outputs["rust_matrix"]["include"]}, + {"relay-client", "relay-v2"}, + ) + relay_client_matrix = next( + entry + for entry in outputs["rust_matrix"]["include"] + if entry["name"] == "relay-client" + ) + self.assertFalse(relay_client_matrix["all_features"]) + def test_oid4vci_change_runs_rust_contracts_and_its_registered_tutorial(self) -> None: outputs = classify( self.workspace, @@ -652,6 +684,10 @@ def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: ) self.assertIn("\n relay-contracts:\n", workflow) self.assertIn("name: Relay OpenAPI contract", workflow) + self.assertIn("\n relay-client-contracts:\n", workflow) + self.assertIn( + "products/relay-v2/scripts/check-client-contract.sh", workflow + ) self.assertNotIn("\n notary-contracts:\n", workflow) self.assertNotIn("notary_contracts", workflow) @@ -660,6 +696,7 @@ def test_current_contract_gates_replace_the_retired_notary_gate(self) -> None: )[0] self.assertIn("\n - evidence-contracts\n", rust_result) self.assertIn("\n - relay-contracts\n", rust_result) + self.assertIn("\n - relay-client-contracts\n", rust_result) self.assertNotIn("\n - notary-contracts\n", rust_result) def test_archive_content_is_immutable_during_routine_docs_changes(self) -> None: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b7d32133..8b013e378 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,7 @@ jobs: platform_hygiene: ${{ steps.filter.outputs.platform_hygiene }} relay_contracts: ${{ steps.filter.outputs.relay_contracts }} relay_v2_contracts: ${{ steps.filter.outputs.relay_v2_contracts }} + relay_client_contracts: ${{ steps.filter.outputs.relay_client_contracts }} evidence_contracts: ${{ steps.filter.outputs.evidence_contracts }} project_authoring: ${{ steps.filter.outputs.project_authoring }} release_tool: ${{ steps.filter.outputs.release_tool }} @@ -570,6 +571,32 @@ jobs: - name: Relay V2 coequal HTTP journeys run: products/relay-v2/scripts/test-http.sh + relay-client-contracts: + name: Relay client contract and source neutrality + needs: changes + if: needs.changes.outputs.relay_client_contracts == 'true' + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + persist-credentials: false + submodules: false + + - name: Cache Cargo registry + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + shared-key: workspace-registry + cache-targets: false + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Relay client contract consistency + run: products/relay-v2/scripts/check-client-contract.sh + + - name: Relay client source neutrality + run: products/relay-v2/scripts/check-source-neutrality.sh + identifiers: name: Public identifier catalog needs: changes @@ -604,6 +631,7 @@ jobs: - evidence-contracts - relay-contracts - relay-v2-contracts + - relay-client-contracts - identifiers runs-on: ubuntu-24.04 env: @@ -1188,7 +1216,7 @@ jobs: cmp LICENSE editors/zed/LICENSE client-bindings: - name: Evidence client bindings + name: Native client bindings needs: changes if: needs.changes.outputs.client_bindings == 'true' runs-on: ubuntu-24.04 @@ -1204,23 +1232,37 @@ jobs: with: node-version: 22.12.0 cache: npm - cache-dependency-path: crates/registry-evidence-client-node/package-lock.json + cache-dependency-path: | + crates/registry-evidence-client-node/package-lock.json + crates/registry-relay-client-node/package-lock.json - - name: Build and test the Node binding - working-directory: crates/registry-evidence-client-node + - name: Build and test Node bindings + shell: bash run: | - npm ci - npm run build:debug - npm test - npm run check:types - cmp ../../LICENSE LICENSE + set -euo pipefail + for client in registry-evidence-client-node registry-relay-client-node; do + ( + cd "crates/${client}" + npm ci + npm run build:debug + npm test + npm run check:types + cmp ../../LICENSE LICENSE + ) + done - - name: Build and test the Python binding - working-directory: crates/registry-evidence-client-py + - name: Build and test Python bindings + shell: bash run: | - cargo build --locked -p registry-evidence-client-py --lib --features registry-evidence-client-py/extension-module - python3 -m unittest discover -s tests/python -v - cmp ../../LICENSE LICENSE + set -euo pipefail + for client in registry-evidence-client-py registry-relay-client-py; do + cargo build --locked -p "${client}" --lib --features "${client}/extension-module" + ( + cd "crates/${client}" + python3 -m unittest discover -s tests/python -v + cmp ../../LICENSE LICENSE + ) + done ci-result: name: CI result diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index def66b472..8b447d41f 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -370,7 +370,7 @@ jobs: retention-days: 2 clients: - name: Build Evidence client packages for ${{ matrix.asset }} + name: Build native client packages for ${{ matrix.asset }} needs: validate runs-on: ${{ matrix.runner }} timeout-minutes: 40 @@ -410,100 +410,124 @@ jobs: with: node-version: 22.12.0 cache: npm - cache-dependency-path: crates/registry-evidence-client-node/package-lock.json + cache-dependency-path: | + crates/registry-evidence-client-node/package-lock.json + crates/registry-relay-client-node/package-lock.json - - name: Restore Evidence client Cargo cache + - name: Restore native client Cargo cache uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v4.2.3 with: path: | ~/.cargo/registry ~/.cargo/git target - key: registry-evidence-release-clients-${{ matrix.asset }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }} + key: registry-stack-release-clients-${{ matrix.asset }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.lock') }} restore-keys: | - registry-evidence-release-clients-${{ matrix.asset }}- + registry-stack-release-clients-${{ matrix.asset }}- - - name: Build the Python client wheel - working-directory: crates/registry-evidence-client-py + - name: Build Python client wheels shell: bash run: | set -euo pipefail rustup toolchain install 1.95.0 --profile minimal - configured_version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" - test "${configured_version}" = "${CLIENT_VERSION}" python3 -m venv "${RUNNER_TEMP}/maturin" "${RUNNER_TEMP}/maturin/bin/pip" install --quiet \ --require-hashes --only-binary=:all: \ --requirement "${GITHUB_WORKSPACE}/release/requirements/maturin-1.9.6.txt" wheel_dir="${GITHUB_WORKSPACE}/candidate-client-package/clients" mkdir -p "${wheel_dir}" - if [[ "${RUNNER_OS}" == Linux ]]; then - "${RUNNER_TEMP}/maturin/bin/maturin" build \ - --release --locked --compatibility linux --out "${wheel_dir}" - else - "${RUNNER_TEMP}/maturin/bin/maturin" build \ - --release --locked --out "${wheel_dir}" - fi - wheel="registry_evidence_client-${CLIENT_VERSION}-${{ matrix.wheel_tag }}.whl" - if [[ ! -f "${wheel_dir}/${wheel}" ]]; then - echo "maturin did not produce ${wheel}" >&2 - find "${wheel_dir}" -maxdepth 1 -name '*.whl' >&2 - exit 1 - fi - if [[ "$(find "${wheel_dir}" -maxdepth 1 -name '*.whl' | wc -l)" -ne 1 ]]; then - echo "expected exactly one wheel for ${{ matrix.asset }}" >&2 - exit 1 + for client in evidence relay; do + if [[ "${client}" == relay ]] && + [[ "${CLIENT_VERSION}" =~ ^0\.([0-9]|1[0-8])\. || "${CLIENT_VERSION}" == "0.19.0" ]]; then + continue + fi + client_dir="${GITHUB_WORKSPACE}/crates/registry-${client}-client-py" + configured_version="$(cd "${client_dir}" && python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + test "${configured_version}" = "${CLIENT_VERSION}" + if [[ "${RUNNER_OS}" == Linux ]]; then + (cd "${client_dir}" && "${RUNNER_TEMP}/maturin/bin/maturin" build \ + --release --locked --compatibility linux --out "${wheel_dir}") + else + (cd "${client_dir}" && "${RUNNER_TEMP}/maturin/bin/maturin" build \ + --release --locked --out "${wheel_dir}") + fi + wheel="registry_${client}_client-${CLIENT_VERSION}-${{ matrix.wheel_tag }}.whl" + if [[ ! -f "${wheel_dir}/${wheel}" ]]; then + echo "maturin did not produce ${wheel}" >&2 + find "${wheel_dir}" -maxdepth 1 -name '*.whl' >&2 + exit 1 + fi + done + expected_wheels=1 + if [[ ! "${CLIENT_VERSION}" =~ ^0\.([0-9]|1[0-8])\. ]] && + [[ "${CLIENT_VERSION}" != "0.19.0" ]]; then + expected_wheels=2 fi + test "$(find "${wheel_dir}" -maxdepth 1 -name '*.whl' | wc -l)" -eq "${expected_wheels}" - - name: Smoke the Python client wheel + - name: Smoke Python client wheels shell: bash run: | set -euo pipefail - wheel="registry_evidence_client-${CLIENT_VERSION}-${{ matrix.wheel_tag }}.whl" - python3 -m venv "${RUNNER_TEMP}/wheel-smoke" - "${RUNNER_TEMP}/wheel-smoke/bin/pip" install --quiet \ - "${GITHUB_WORKSPACE}/candidate-client-package/clients/${wheel}" - "${RUNNER_TEMP}/wheel-smoke/bin/python" \ - release/scripts/smoke-evidence-client-package.py + for client in evidence relay; do + if [[ "${client}" == relay ]] && + [[ "${CLIENT_VERSION}" =~ ^0\.([0-9]|1[0-8])\. || "${CLIENT_VERSION}" == "0.19.0" ]]; then + continue + fi + wheel="registry_${client}_client-${CLIENT_VERSION}-${{ matrix.wheel_tag }}.whl" + smoke="${RUNNER_TEMP}/wheel-smoke-${client}" + python3 -m venv "${smoke}" + "${smoke}/bin/pip" install --quiet \ + "${GITHUB_WORKSPACE}/candidate-client-package/clients/${wheel}" + "${smoke}/bin/python" \ + "release/scripts/smoke-${client}-client-package.py" + done - - name: Build the Node client package - working-directory: crates/registry-evidence-client-node + - name: Build Node client packages shell: bash run: | set -euo pipefail - npm ci - test "$(node -p "require('./package.json').version")" = "${CLIENT_VERSION}" - npm run build - npm pack --pack-destination "${RUNNER_TEMP}" - packed="${RUNNER_TEMP}/registrystack-evidence-client-${CLIENT_VERSION}.tgz" - if [[ ! -f "${packed}" ]]; then - echo "npm pack did not produce ${packed}" >&2 - exit 1 - fi - entries="$(tar -tzf "${packed}")" - if ! grep -Fxq "package/evidence-client.${{ matrix.napi_platform }}.node" \ - <<<"${entries}"; then - echo "the packed tarball has no evidence-client.${{ matrix.napi_platform }}.node" >&2 - printf '%s\n' "${entries}" >&2 - exit 1 - fi - cp "${packed}" \ - "${GITHUB_WORKSPACE}/candidate-client-package/clients/evidence-client-node-${CLIENT_TAG}-${{ matrix.asset }}.tgz" + for client in evidence relay; do + if [[ "${client}" == relay ]] && + [[ "${CLIENT_VERSION}" =~ ^0\.([0-9]|1[0-8])\. || "${CLIENT_VERSION}" == "0.19.0" ]]; then + continue + fi + client_dir="${GITHUB_WORKSPACE}/crates/registry-${client}-client-node" + (cd "${client_dir}" && npm ci) + test "$(node -p "require('${client_dir}/package.json').version")" = "${CLIENT_VERSION}" + (cd "${client_dir}" && npm run build) + (cd "${client_dir}" && npm pack --pack-destination "${RUNNER_TEMP}") + packed="${RUNNER_TEMP}/registrystack-${client}-client-${CLIENT_VERSION}.tgz" + test -f "${packed}" + entries="$(tar -tzf "${packed}")" + grep -Fxq "package/${client}-client.${{ matrix.napi_platform }}.node" \ + <<<"${entries}" + cp "${packed}" \ + "${GITHUB_WORKSPACE}/candidate-client-package/clients/${client}-client-node-${CLIENT_TAG}-${{ matrix.asset }}.tgz" + done - - name: Smoke the Node client package + - name: Smoke Node client packages shell: bash run: | set -euo pipefail - tarball="${GITHUB_WORKSPACE}/candidate-client-package/clients/evidence-client-node-${CLIENT_TAG}-${{ matrix.asset }}.tgz" - smoke="${RUNNER_TEMP}/node-smoke" - mkdir -p "${smoke}" - cd "${smoke}" - npm init --yes >/dev/null - npm install --no-audit --no-fund "${tarball}" - cp "${GITHUB_WORKSPACE}/release/scripts/smoke-evidence-client-package.js" . - node smoke-evidence-client-package.js + for client in evidence relay; do + if [[ "${client}" == relay ]] && + [[ "${CLIENT_VERSION}" =~ ^0\.([0-9]|1[0-8])\. || "${CLIENT_VERSION}" == "0.19.0" ]]; then + continue + fi + tarball="${GITHUB_WORKSPACE}/candidate-client-package/clients/${client}-client-node-${CLIENT_TAG}-${{ matrix.asset }}.tgz" + smoke="${RUNNER_TEMP}/node-smoke-${client}" + mkdir -p "${smoke}" + ( + cd "${smoke}" + npm init --yes >/dev/null + npm install --no-audit --no-fund "${tarball}" + cp "${GITHUB_WORKSPACE}/release/scripts/smoke-${client}-client-package.js" . + node "smoke-${client}-client-package.js" + ) + done - - name: Upload Evidence client packages + - name: Upload native client packages uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: candidate-clients-${{ matrix.asset }}-${{ github.run_id }}-${{ github.run_attempt }} @@ -768,9 +792,19 @@ jobs: rm -f candidate/bundle-root/SHA256SUMS cp "${macos}"/platform/* candidate/bundle-root/ cp "${linux_arm}"/platform/* candidate/bundle-root/ + version="${{ needs.validate.outputs.version }}" + include_relay_clients=0 + if [[ ! "${version}" =~ ^0\.([0-9]|1[0-8])\. ]] && + [[ "${version}" != "0.19.0" ]]; then + include_relay_clients=1 + fi for platform in linux-amd64-glibc linux-arm64-glibc macos-arm64; do client_root="inputs/candidate-clients-${platform}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}/clients" - test "$(find "${client_root}" -maxdepth 1 -type f | wc -l)" -eq 2 + expected_client_assets=2 + if [[ "${include_relay_clients}" -eq 1 ]]; then + expected_client_assets=4 + fi + test "$(find "${client_root}" -maxdepth 1 -type f | wc -l)" -eq "${expected_client_assets}" cp "${client_root}"/* candidate/bundle-root/ done { @@ -780,15 +814,24 @@ jobs: for wheel_platform in linux_x86_64 linux_aarch64 macosx_11_0_arm64; do echo "registry_evidence_client-${{ needs.validate.outputs.version }}-cp310-abi3-${wheel_platform}.whl" done + if [[ "${include_relay_clients}" -eq 1 ]]; then + for platform in linux-amd64-glibc linux-arm64-glibc macos-arm64; do + echo "relay-client-node-${{ needs.validate.outputs.tag }}-${platform}.tgz" + done + for wheel_platform in linux_x86_64 linux_aarch64 macosx_11_0_arm64; do + echo "registry_relay_client-${{ needs.validate.outputs.version }}-cp310-abi3-${wheel_platform}.whl" + done + fi } | sort > "${RUNNER_TEMP}/expected-client-assets" find candidate/bundle-root -maxdepth 1 -type f \ \( -name 'evidence-client-node-*.tgz' \ - -o -name 'registry_evidence_client-*.whl' \) \ + -o -name 'registry_evidence_client-*.whl' \ + -o -name 'relay-client-node-*.tgz' \ + -o -name 'registry_relay_client-*.whl' \) \ -printf '%f\n' | sort > "${RUNNER_TEMP}/actual-client-assets" diff -u \ "${RUNNER_TEMP}/expected-client-assets" \ "${RUNNER_TEMP}/actual-client-assets" - version="${{ needs.validate.outputs.version }}" IFS=. read -r relay_major relay_minor relay_patch <<< "${version}" if (( relay_major > 0 || relay_minor > 19 || (relay_minor == 19 && relay_patch >= 1) )); then @@ -898,7 +941,7 @@ jobs: *.sbom.spdx.json) kind=sbom ;; *-security-evidence.tar.gz) kind=security-evidence ;; *-install.sh) kind=installer ;; - evidence-client-node-*.tgz|registry_evidence_client-*.whl) kind=client-package ;; + evidence-client-node-*.tgz|registry_evidence_client-*.whl|relay-client-node-*.tgz|registry_relay_client-*.whl) kind=client-package ;; *) kind=binary ;; esac jq -n \ diff --git a/Cargo.lock b/Cargo.lock index ffb39808d..4bda378c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5698,7 +5698,6 @@ dependencies = [ name = "registry-evidence-client" version = "0.19.0" dependencies = [ - "async-trait", "base64", "chrono", "ed25519-dalek", @@ -5707,8 +5706,8 @@ dependencies = [ "registry-evidence", "registry-evidence-verifier", "registry-mint", - "registry-platform-authcommon", "registry-platform-crypto", + "registry-platform-httpsec", "registry-platform-httputil", "registry-platform-sdjwt", "reqwest 0.12.28", @@ -6053,14 +6052,20 @@ dependencies = [ name = "registry-platform-httputil" version = "0.19.0" dependencies = [ + "async-trait", "axum", "base64", "bytes", + "chrono", + "ed25519-dalek", + "getrandom 0.4.3", "hickory-resolver", "http", "ipnet", + "p256", "proptest", "rcgen", + "registry-platform-authcommon", "registry-platform-canonical-json", "registry-platform-crypto", "reqwest 0.12.28", @@ -6073,6 +6078,7 @@ dependencies = [ "tokio-rustls", "url", "uuid", + "wiremock", "zeroize", ] @@ -6252,6 +6258,59 @@ dependencies = [ "zip", ] +[[package]] +name = "registry-relay-client" +version = "0.19.0" +dependencies = [ + "async-trait", + "axum", + "registry-platform-httpsec", + "registry-platform-httputil", + "registry-relay-http-contract", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "url", + "zeroize", +] + +[[package]] +name = "registry-relay-client-node" +version = "0.19.0" +dependencies = [ + "axum", + "napi", + "napi-build", + "napi-derive", + "registry-platform-crypto", + "registry-relay-client", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "registry-relay-client-py" +version = "0.19.0" +dependencies = [ + "pyo3", + "pyo3-build-config", + "registry-platform-crypto", + "registry-relay-client", + "serde", + "serde_json", + "tokio", + "url", + "wiremock", +] + +[[package]] +name = "registry-relay-http-contract" +version = "0.19.0" + [[package]] name = "registry-relay-v2" version = "0.19.0" @@ -6282,6 +6341,8 @@ dependencies = [ "registry-platform-oidc", "registry-platform-sqlite", "registry-platform-testing", + "registry-relay-client", + "registry-relay-http-contract", "reqwest 0.12.28", "rustix", "schemars 1.2.1", diff --git a/Cargo.toml b/Cargo.toml index e749d4cce..021076ee6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,10 @@ members = [ "crates/registry-manifest-cli", "crates/registry-mint", "crates/registry-relay", + "crates/registry-relay-http-contract", + "crates/registry-relay-client", + "crates/registry-relay-client-node", + "crates/registry-relay-client-py", "crates/registry-relay-v2", "crates/registry-relayctl", "crates/registry-language-server", @@ -61,6 +65,10 @@ registry-language-server = { path = "crates/registry-language-server", version = registry-manifest-core = { path = "crates/registry-manifest-core", version = "0.19.0" } registry-mint = { path = "crates/registry-mint", version = "0.19.0" } registry-relay = { path = "crates/registry-relay", version = "0.19.0" } +registry-relay-http-contract = { path = "crates/registry-relay-http-contract", version = "0.19.0" } +registry-relay-client = { path = "crates/registry-relay-client", version = "0.19.0" } +registry-relay-client-node = { path = "crates/registry-relay-client-node", version = "0.19.0" } +registry-relay-client-py = { path = "crates/registry-relay-client-py", version = "0.19.0" } registry-relay-v2 = { path = "crates/registry-relay-v2", version = "0.19.0" } registry-relayctl = { path = "crates/registry-relayctl", version = "0.19.0" } registry-platform-audit = { path = "crates/registry-platform-audit", version = "0.19.0" } @@ -69,7 +77,7 @@ registry-platform-buildinfo = { path = "crates/registry-platform-buildinfo", ver registry-platform-canonical-json = { path = "crates/registry-platform-canonical-json", version = "0.19.0" } registry-platform-config = { path = "crates/registry-platform-config", version = "0.19.0" } registry-platform-crypto = { path = "crates/registry-platform-crypto", version = "0.19.0" } -registry-platform-httpsec = { path = "crates/registry-platform-httpsec", version = "0.19.0" } +registry-platform-httpsec = { path = "crates/registry-platform-httpsec", version = "0.19.0", default-features = false } registry-platform-httputil = { path = "crates/registry-platform-httputil", version = "0.19.0" } registry-platform-oidc = { path = "crates/registry-platform-oidc", version = "0.19.0" } registry-platform-ops = { path = "crates/registry-platform-ops", version = "0.19.0" } diff --git a/crates/registry-evidence-client/Cargo.toml b/crates/registry-evidence-client/Cargo.toml index 6a761dcb8..56da3a192 100644 --- a/crates/registry-evidence-client/Cargo.toml +++ b/crates/registry-evidence-client/Cargo.toml @@ -12,13 +12,12 @@ publish = false workspace = true [dependencies] -async-trait.workspace = true base64.workspace = true chrono.workspace = true getrandom.workspace = true registry-evidence-verifier.workspace = true -registry-platform-authcommon.workspace = true registry-platform-crypto.workspace = true +registry-platform-httpsec = { workspace = true, default-features = false } registry-platform-httputil.workspace = true reqwest.workspace = true serde.workspace = true diff --git a/crates/registry-evidence-client/src/client.rs b/crates/registry-evidence-client/src/client.rs index 9e0b287b7..858a7cdc1 100644 --- a/crates/registry-evidence-client/src/client.rs +++ b/crates/registry-evidence-client/src/client.rs @@ -12,13 +12,19 @@ use registry_evidence_verifier::{ verifier::ExpectedSubjectDocument, EVIDENCE_REQUEST_BATCH_MEDIA_TYPE, EVIDENCE_REQUEST_BATCH_SCHEMA_V1, }; -use registry_platform_httputil::read_bounded; +use registry_platform_httputil::{read_bounded, retry_after_seconds, validate_response_headers}; use reqwest::{ - header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE, RETRY_AFTER}, + header::{HeaderMap, ACCEPT, AUTHORIZATION, CONTENT_TYPE}, Method, StatusCode, }; use url::Url; -use zeroize::Zeroizing; + +#[cfg(test)] +use crate::problem::TRACEPARENT_HEADER; +#[cfg(test)] +use reqwest::header::HeaderValue; +#[cfg(test)] +use reqwest::header::RETRY_AFTER; use crate::{ batch::{SdJwtVcBatchResponse, MAX_SD_JWT_VC_BATCH_RESPONSE_BYTES}, @@ -30,7 +36,7 @@ use crate::{ EvidenceRequestSpec, HolderBoundRequestSpec, PreparedEvidenceRequest, PreparedHolderBoundRequest, }, - problem::{essence, map_problem, trace_id_from_traceparent, TRACEPARENT_HEADER}, + problem::{essence, map_problem}, request_batch::{ EvidenceRequestBatchSpec, PreparedEvidenceRequestBatch, RawEvidenceRequestBatchResponse, VerifiedEvidenceRequestBatch, VerifiedEvidenceRequestBatchItem, @@ -610,21 +616,7 @@ impl EvidenceClient { let request = match credential { Credential::Required => { let token = self.config.token_provider.bearer_token().await?; - // The plaintext credential exists in one scrubbed buffer here. - // The header value reqwest owns afterwards cannot be zeroized, - // which is why it is marked sensitive below. - let mut header = Zeroizing::new(String::with_capacity(7 + token.expose().len())); - header.push_str("Bearer "); - header.push_str(token.expose()); - let mut value = HeaderValue::from_str(&header).map_err(|_| { - EvidenceClientError::configuration( - "the credential is not a usable header value", - ) - })?; - // The credential must never reach a diagnostic, and reqwest - // honors this marking when it formats a request. - value.set_sensitive(true); - request.header(AUTHORIZATION, value) + request.header(AUTHORIZATION, token.authorization_header_value()) } Credential::None => request, }; @@ -647,18 +639,22 @@ impl EvidenceClient { max_bytes: u64, ) -> Result { let status = response.status().as_u16(); + if validate_response_headers(response.headers()).is_err() { + return Err(EvidenceClientError::Protocol { + status, + code: None, + trace_id: None, + retry_after_seconds: None, + }); + } let trace_id = response_trace_id(response.headers()); let media_type = response .headers() .get(CONTENT_TYPE) .and_then(|value| value.to_str().ok()) .map(str::to_owned); - let retry_after_seconds = response - .headers() - .get(RETRY_AFTER) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.trim().parse::().ok()) - .filter(|seconds| (1..=MAXIMUM_RETRY_AFTER_SECONDS).contains(seconds)); + let retry_after_seconds = + retry_after_seconds(response.headers(), MAXIMUM_RETRY_AFTER_SECONDS); if trace_id.is_none() { return Err(EvidenceClientError::Protocol { @@ -819,11 +815,9 @@ fn batch_protocol_failure(trace_id: Option) -> EvidenceClientError { /// Read exactly one strict response trace context. Multiple field lines are an /// ambiguous provenance claim, so they are rejected rather than first-wins. fn response_trace_id(headers: &HeaderMap) -> Option { - let mut traceparents = headers.get_all(TRACEPARENT_HEADER).iter(); - match (traceparents.next(), traceparents.next()) { - (Some(value), None) => value.to_str().ok().and_then(trace_id_from_traceparent), - _ => None, - } + registry_platform_httpsec::response_trace_id(headers) + .ok() + .map(|trace_id| trace_id.as_str().to_owned()) } /// Build the outbound client from the pinned deployment options. diff --git a/crates/registry-evidence-client/src/error.rs b/crates/registry-evidence-client/src/error.rs index 00af74d20..7c066d5d2 100644 --- a/crates/registry-evidence-client/src/error.rs +++ b/crates/registry-evidence-client/src/error.rs @@ -10,6 +10,7 @@ use registry_evidence_verifier::verifier::VerificationError; use thiserror::Error; use crate::{nonce::NonceError, token::TokenError}; +pub use registry_platform_httputil::TransportKind; #[derive(Debug, Error, Clone, PartialEq, Eq)] #[non_exhaustive] @@ -72,45 +73,6 @@ pub enum EvidenceClientError { Verification(VerificationError), } -/// Coarse reason an exchange did not complete. -/// -/// TLS failures are reported as `Connect`: distinguishing them would mean -/// reading a transport error chain whose text this crate must not copy into a -/// diagnostic. -#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum TransportKind { - #[error("connection setup failed")] - Connect, - #[error("the configured timeout elapsed")] - Timeout, - #[error("the exchange failed")] - Exchange, - #[error("the response body exceeded the configured maximum")] - ResponseTooLarge, -} - -impl TransportKind { - /// A stable, machine-readable name for which kind of transport failure - /// this is. - /// - /// It exists for callers that have to branch or aggregate without matching - /// an enum this crate may extend: a metric label, a structured log field, or - /// a language binding that carries the discriminant across a boundary. The - /// rendered message is for people and may be reworded; these names are part - /// of the crate's contract and will not be renamed. A variant added later - /// brings a new name rather than reusing one of these. - #[must_use] - pub fn kind(&self) -> &'static str { - match self { - Self::Connect => "connect", - Self::Timeout => "timeout", - Self::Exchange => "exchange", - Self::ResponseTooLarge => "response_too_large", - } - } -} - impl EvidenceClientError { pub(crate) fn configuration(reason: &'static str) -> Self { Self::Configuration { reason } diff --git a/crates/registry-evidence-client/src/outbound.rs b/crates/registry-evidence-client/src/outbound.rs index a25cd3db3..0830fc2b1 100644 --- a/crates/registry-evidence-client/src/outbound.rs +++ b/crates/registry-evidence-client/src/outbound.rs @@ -1,148 +1,6 @@ -//! The one rule set every outbound exchange in this crate is built from. -//! -//! Two exchanges leave this crate: the Evidence request, which carries the -//! relying party's bearer credential, and the token request, which carries a -//! signed client assertion. Both hand a secret to a host the integrator named, -//! so both are built here rather than from two rule sets that could drift apart. +//! Compatibility bridge to the shared outbound client primitives. -use std::{borrow::Cow, time::Duration}; - -use registry_platform_httputil::BoundedReadError; -use url::Url; - -use crate::error::TransportKind; - -/// The one host name a cleartext URL may carry. It is reserved for the loopback -/// interface, so a credential sent to it cannot leave the host. -const LOOPBACK_NAME: &str = "localhost"; - -/// What a caller may vary about an outbound client. Everything else is fixed by -/// [`build_client`]. -#[derive(Debug, Clone, Copy)] -pub(crate) struct OutboundOptions<'a> { - pub(crate) request_timeout: Duration, - pub(crate) connect_timeout: Duration, - pub(crate) user_agent: Option<&'a str>, - pub(crate) trusted_root_certificates: Option<&'a [u8]>, -} - -/// Build an outbound client. -/// -/// A failure is fixed text naming what about the options is unusable. Each -/// caller wraps it in its own error vocabulary, because the two exchanges report -/// configuration failures to the adopter under different types. -pub(crate) fn build_client(options: OutboundOptions<'_>) -> Result { - let mut builder = reqwest::Client::builder() - .timeout(options.request_timeout) - .connect_timeout(options.connect_timeout) - // A redirect is not part of the response contract, and following one - // would present the relying party's credential to a host the integrator - // never configured, on the say-so of a response header. The answer is - // reported as it stands instead. - .redirect(reqwest::redirect::Policy::none()) - // The proxy environment variables are ignored deliberately. An ambient - // variable would otherwise route a credential through an intermediary the - // integrator did not choose, and terminate the TLS session the pinned - // certificate authorities were meant to authenticate. - .no_proxy() - // Select rustls explicitly. Cargo unifies reqwest's feature set across - // a whole build, so another crate enabling reqwest's native-tls feature - // must not silently change which TLS backend this client uses. - .use_rustls_tls() - // One prepared request is one exchange. A transport-level retry would - // resend a nonce the relying party's policy has already committed to - // and would duplicate an outbound call the caller did not ask for. - .retry(reqwest::retry::never()); - if let Some(user_agent) = options.user_agent { - builder = builder.user_agent(user_agent); - } - if let Some(pem) = options.trusted_root_certificates { - let certificates = reqwest::Certificate::from_pem_bundle(pem) - .map_err(|_| "the pinned certificate authority bundle is not readable PEM")?; - if certificates.is_empty() { - return Err("the pinned certificate authority bundle carries no certificate"); - } - for certificate in certificates { - builder = builder.add_root_certificate(certificate); - } - // Trust exactly what the integrator pinned. Leaving the platform store - // enabled would mean any of its authorities could also vouch for the - // deployment, which is the opposite of pinning. - builder = builder.tls_built_in_root_certs(false); - } - builder - .build() - .map_err(|_| "the outbound client options are not usable") -} - -/// Whether this URL's transport keeps a secret sent to it away from the network. -/// -/// A secret in cleartext is only acceptable when it cannot leave the host, which -/// is the local development and tutorial case. The accepted forms are the ones an -/// adopter types: either loopback numeric family, or the reserved name -/// `localhost`. Any other name is refused, because a name that happens to resolve -/// to a loopback address is still resolved off-host, and the answer can change. -pub(crate) fn transport_protects_the_credential(url: &Url) -> bool { - match url.scheme() { - "https" => true, - "http" => url.host().is_some_and(|host| match host { - url::Host::Ipv4(ip) => ip.is_loopback(), - url::Host::Ipv6(ip) => ip.is_loopback(), - url::Host::Domain(name) => name == LOOPBACK_NAME, - }), - _ => false, - } -} - -/// The base URL with any userinfo removed. -/// -/// [`EvidenceClientConfig::validate`] refuses a base URL carrying credentials, -/// but it runs inside `EvidenceClient::new`, so the rendering cannot rely on -/// having been reached after construction. -pub(crate) fn base_url_without_userinfo(base_url: &Url) -> Cow<'_, str> { - if base_url.username().is_empty() && base_url.password().is_none() { - return Cow::Borrowed(base_url.as_str()); - } - let mut stripped = base_url.clone(); - // Both setters refuse only a URL that cannot carry userinfo at all, and this - // point is reached only for a URL that carries some, so neither can refuse - // here. A refusal withholds the whole URL rather than rendering a credential. - if stripped.set_username("").is_err() || stripped.set_password(None).is_err() { - return Cow::Borrowed(""); - } - Cow::Owned(stripped.into()) -} - -/// Why a send failed, in the terms the caller can act on. -pub(crate) fn send_failure_kind(error: &reqwest::Error) -> TransportKind { - if error.is_timeout() { - TransportKind::Timeout - } else if error.is_connect() { - // TLS negotiation failures arrive here too. Separating them would mean - // reading a transport error chain whose text this crate must not copy - // into a diagnostic. - TransportKind::Connect - } else { - TransportKind::Exchange - } -} - -/// Why a bounded read failed, in the terms the caller can act on. -/// -/// The distinction matters most for a timeout, which is the likely failure: the -/// configured total timeout runs until the body finishes, so an answer that -/// starts and stalls elapses here rather than at connection setup. No part of the -/// underlying error text is copied into the reported failure. -pub(crate) fn read_failure_kind(error: &BoundedReadError) -> TransportKind { - match error { - BoundedReadError::ContentLengthExceeded { .. } - | BoundedReadError::BodyTooLarge { .. } - | BoundedReadError::LengthOverflow => TransportKind::ResponseTooLarge, - BoundedReadError::Transport(error) if error.is_timeout() => TransportKind::Timeout, - // The reader's error type is open, so a variant this crate does not know - // yet becomes the coarse exchange failure. It must never become a claim - // about the response size, which is the one thing an adopter would act on - // by raising their own bound. - _ => TransportKind::Exchange, - } -} +pub(crate) use registry_platform_httputil::client::{ + base_url_without_userinfo, build_client, read_failure_kind, send_failure_kind, + transport_protects_the_credential, OutboundOptions, +}; diff --git a/crates/registry-evidence-client/src/private_key_jwt.rs b/crates/registry-evidence-client/src/private_key_jwt.rs index 83d3dcec1..75463e8f9 100644 --- a/crates/registry-evidence-client/src/private_key_jwt.rs +++ b/crates/registry-evidence-client/src/private_key_jwt.rs @@ -1,1715 +1,7 @@ -//! Token acquisition with a signed client assertion. -//! -//! This is the OAuth 2.0 `client_credentials` grant with the `private_key_jwt` -//! client authentication method of RFC 7523 section 2.2: the client proves who it -//! is by signing a short-lived assertion with a key only it holds, so no shared -//! secret ever leaves the process or sits in a deployment's configuration. -//! -//! It is plain OAuth. Nothing here knows which authorization server it is talking -//! to, and the provider carries no claim, route, or vocabulary belonging to any -//! particular issuer. The request body carries only `grant_type`, -//! `client_assertion_type`, and `client_assertion`; a server that also requires a -//! scope, a resource indicator, or a body `client_id` on this grant needs support -//! this provider does not offer. -//! -//! The assertion itself is built by -//! [`registry_platform_authcommon::client_assertion`]. Nothing else in the -//! stack calls that builder yet: `registry-mint`'s own caller tooling -//! (`crates/registry-mint/src/caller.rs`) signs a client assertion for testing -//! Mint's token endpoint, but it builds its own claims, header, and algorithm -//! mapping rather than reusing this one. What this module owns is the token -//! request that presents one and the credential it is exchanged for. -//! -//! # What is cached, and for how long -//! -//! An access token is reused until it has less life left than the refresh margin, -//! at which point the next caller acquires a replacement. The margin exists -//! because a credential that is valid when the request is built may have expired -//! by the time the deployment reads it. A server that states no lifetime has given -//! nothing to cache against, so each request acquires its own credential. The -//! deadline is measured against a reading that only moves forward, so correcting -//! the host clock cannot extend how long a credential is presented for. +//! Compatibility re-exports for the product-neutral private-key-JWT provider. -use std::{ - fmt, - sync::Arc, - time::{Duration, Instant}, +pub use registry_platform_httputil::{ + PrivateKeyJwt, PrivateKeyJwtConfig, DEFAULT_ASSERTION_LIFETIME_SECONDS, + DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, }; - -use async_trait::async_trait; -use chrono::Utc; -use registry_platform_authcommon::client_assertion::{ - sign_client_assertion, ClientAssertionError, ClientAssertionRequest, -}; -use registry_platform_crypto::PrivateJwk; -use registry_platform_httputil::read_bounded; -use reqwest::header::{ACCEPT, CONTENT_TYPE}; -use serde::Deserialize; -use tokio::sync::{Mutex, RwLock}; -use url::Url; -use zeroize::Zeroizing; - -use crate::{ - config::{DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT}, - outbound::{ - self, base_url_without_userinfo, transport_protects_the_credential, OutboundOptions, - }, - problem::essence, - token::{BearerToken, OAuthErrorCode, TokenError, TokenProvider}, -}; - -/// How long an assertion is good for by default, and the longest one this -/// provider will sign. -/// -/// Both bound the assertion rather than the token request it is presented on, -/// so they belong to the builder. They are re-exported because an integrator -/// configuring this provider is the one who has to stay inside them. -pub use registry_platform_authcommon::client_assertion::{ - DEFAULT_ASSERTION_LIFETIME_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, -}; - -/// What the constructor signs to prove the client key can sign at all. -/// -/// It carries a space and no `.`, so it cannot be read as the -/// `base64url(header).base64url(claims)` a client assertion is signed over. The -/// signature is discarded, and could not stand in for one even if it were not. -const CLIENT_KEY_PROBE: &[u8] = b"registry-evidence-client client key usability probe"; - -/// How much of an access token's remaining life is treated as already spent. -pub const DEFAULT_REFRESH_MARGIN_SECONDS: i64 = 30; - -/// Longest an issuer's stated `expires_in` is trusted for, when deciding how -/// long to cache the credential it came with. -/// -/// `expires_in` is a remote-controlled value. An authorization server that -/// reports one far longer than any real access token lives, whether by a bug -/// or by intent, must not be able to keep a credential cached, and therefore -/// live in memory, for the life of the process with no way for the integrator -/// to evict it. Re-acquiring a token earlier than an issuer's stated lifetime -/// requires is always safe, so clamping to 86400 seconds (24 hours) cannot -/// break a correct deployment. -pub const MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS: i64 = 86_400; -// Ties the doc comment above to the constant, so the two cannot drift apart. -const _: () = assert!(MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS == 86_400); - -/// The grant this provider asks for. The client authenticates as itself, on its -/// own behalf, which is the only grant an Evidence relying party needs. -const GRANT_TYPE: &str = "client_credentials"; - -/// The client authentication method of RFC 7523 section 2.2. -const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; - -const FORM_MEDIA_TYPE: &str = "application/x-www-form-urlencoded"; -const JSON_MEDIA_TYPE: &str = "application/json"; - -/// The only token type an `Authorization: Bearer` request can present. Compared -/// without case, as RFC 6749 section 5.1 requires. -const BEARER_TOKEN_TYPE: &str = "bearer"; - -/// Longest token response this provider will read. A token response is a small -/// JSON object; anything larger is not one. -const MAXIMUM_TOKEN_RESPONSE_BYTES: u64 = 16 * 1024; - -/// The two readings the provider reasons about. -/// -/// They are separate because they answer different questions. An assertion claim -/// is a wall-clock time the authorization server checks against its own clock, -/// so nothing else will do there. A cache deadline is only ever compared against -/// a later reading of the same clock, and a wall-clock one would move whenever -/// the host clock is corrected. Both come from one source a test can drive, -/// rather than from readings of the host taken wherever they are needed. -pub(crate) trait Clock: Send + Sync { - fn unix_seconds(&self) -> i64; - - /// A reading that only ever moves forward, however the host clock is set. - fn monotonic(&self) -> Instant; -} - -/// The host clock. -struct SystemClock; - -impl Clock for SystemClock { - fn unix_seconds(&self) -> i64 { - Utc::now().timestamp() - } - - fn monotonic(&self) -> Instant { - Instant::now() - } -} - -/// What an integrator decides before the provider can authenticate. -pub struct PrivateKeyJwtConfig { - token_endpoint: Url, - client_id: String, - client_key: PrivateJwk, - audience: Option, - assertion_lifetime_seconds: i64, - refresh_margin_seconds: i64, - request_timeout: Duration, - connect_timeout: Duration, - user_agent: Option, - trusted_root_certificates: Option>>, -} - -impl PrivateKeyJwtConfig { - /// Authenticate as `client_id` at `token_endpoint`, signing with - /// `client_key`. - /// - /// `client_key` may sign with EdDSA, ES256, RS256, ES384, or RS384, and must - /// carry a key identifier: the identifier is how the authorization server - /// selects the registered public key to check the assertion against. The - /// assertion header names whichever of the five the key states, so the - /// server needs that algorithm among the ones it accepts. - #[must_use] - pub fn new(token_endpoint: Url, client_id: impl Into, client_key: PrivateJwk) -> Self { - Self { - token_endpoint, - client_id: client_id.into(), - client_key, - audience: None, - assertion_lifetime_seconds: DEFAULT_ASSERTION_LIFETIME_SECONDS, - refresh_margin_seconds: DEFAULT_REFRESH_MARGIN_SECONDS, - request_timeout: DEFAULT_REQUEST_TIMEOUT, - connect_timeout: DEFAULT_CONNECT_TIMEOUT, - user_agent: None, - trusted_root_certificates: None, - } - } - - /// State the assertion audience the authorization server expects. - /// - /// The default is the token endpoint URL, which is what RFC 7523 section 3 - /// recommends. Set this only when the server published a different value: an - /// assertion whose audience the server does not recognize is refused as an - /// authentication failure, with no indication of which claim was wrong. - /// - /// Must not be empty; an empty value is refused when the provider is built - /// rather than here. - #[must_use] - pub fn with_audience(mut self, audience: impl Into) -> Self { - self.audience = Some(audience.into()); - self - } - - /// Must be within `1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS`; an out-of-range - /// value is refused when the provider is built rather than here. - #[must_use] - pub fn with_assertion_lifetime_seconds(mut self, seconds: i64) -> Self { - self.assertion_lifetime_seconds = seconds; - self - } - - /// Treat this much of an access token's remaining life as already spent. - /// - /// Must not be negative; a negative value is refused when the provider is - /// built rather than here. - #[must_use] - pub fn with_refresh_margin_seconds(mut self, seconds: i64) -> Self { - self.refresh_margin_seconds = seconds; - self - } - - #[must_use] - pub fn with_request_timeout(mut self, timeout: Duration) -> Self { - self.request_timeout = timeout; - self - } - - #[must_use] - pub fn with_connect_timeout(mut self, timeout: Duration) -> Self { - self.connect_timeout = timeout; - self - } - - #[must_use] - pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { - self.user_agent = Some(user_agent.into()); - self - } - - /// Trust exactly these PEM-encoded certificate authorities for the token - /// endpoint's TLS certificate, instead of the platform's own store. - #[must_use] - pub fn with_trusted_root_certificates(mut self, pem_bundle: impl Into>) -> Self { - self.trusted_root_certificates = Some(Zeroizing::new(pem_bundle.into())); - self - } -} - -impl fmt::Debug for PrivateKeyJwtConfig { - /// The client key and the pinned certificate material are withheld, as is any - /// userinfo in the token endpoint. Only the operational choices and the public - /// identifiers are rendered. - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("PrivateKeyJwtConfig") - .field( - "token_endpoint", - &base_url_without_userinfo(&self.token_endpoint), - ) - .field("client_id", &self.client_id) - .field("audience", &self.audience) - .field( - "assertion_lifetime_seconds", - &self.assertion_lifetime_seconds, - ) - .field("refresh_margin_seconds", &self.refresh_margin_seconds) - .field("request_timeout", &self.request_timeout) - .field("connect_timeout", &self.connect_timeout) - .field("user_agent", &self.user_agent) - .finish_non_exhaustive() - } -} - -/// An access token, and the monotonic instant it stops being worth presenting. -struct CachedToken { - token: BearerToken, - expires_at: Instant, -} - -/// A [`TokenProvider`] that authenticates with a signed assertion and caches what -/// it is issued. -pub struct PrivateKeyJwt { - http: reqwest::Client, - token_endpoint: Url, - client_id: String, - audience: String, - assertion_lifetime_seconds: i64, - refresh_margin_seconds: i64, - client_key: PrivateJwk, - key_id: String, - clock: Arc, - /// The credential in hand, if it is still worth presenting. - cached: RwLock>, - /// Held for the length of one token request, so concurrent callers wait for - /// that request instead of opening one each. - refresh_lock: Mutex<()>, -} - -impl PrivateKeyJwt { - /// Refuse a configuration that cannot authenticate, cannot protect its - /// assertion in transit, or cannot produce an assertion the server it - /// registered with can verify. - /// - /// Every one of these would otherwise fail once per request, as an - /// authentication refusal whose code says nothing about which part was wrong. - pub fn new(config: PrivateKeyJwtConfig) -> Result { - Self::with_clock(config, Arc::new(SystemClock)) - } - - pub(crate) fn with_clock( - config: PrivateKeyJwtConfig, - clock: Arc, - ) -> Result { - let refuse = |reason: &'static str| TokenError::Configuration { reason }; - - if config.client_id.trim().is_empty() { - return Err(refuse("the client identifier must not be empty")); - } - // Only a stated audience can be empty. A caller that stated none gets the - // token endpoint, which is a parsed URL and therefore never is. - if config - .audience - .as_ref() - .is_some_and(|audience| audience.trim().is_empty()) - { - return Err(refuse("the assertion audience must not be empty")); - } - if !config.token_endpoint.username().is_empty() - || config.token_endpoint.password().is_some() - || config.token_endpoint.fragment().is_some() - { - return Err(refuse( - "the token endpoint must carry no credentials or fragment", - )); - } - // The assertion authenticates the client, so it is as sensitive in transit - // as the access token it is exchanged for, and the same transport rule - // applies to both. - if !transport_protects_the_credential(&config.token_endpoint) { - return Err(refuse( - "the token endpoint must use HTTPS, or HTTP with a loopback host", - )); - } - // The assertion header names the algorithm so the server can verify - // without guessing, which means it must state what this key actually - // signs with rather than one fixed name. Restricting the client to a - // single algorithm would refuse keys a conforming authorization server - // accepts: `token_endpoint_auth_signing_alg_values_supported` is the - // server's choice to publish, not this client's to narrow. Parsing a - // `PrivateJwk` already refuses any algorithm the crypto crate does not - // support, so this arm is a floor rather than a path a caller can reach. - // Checking here rather than leaving it to the builder is what keeps the - // promise this constructor makes: a key that cannot produce an assertion - // is refused now, not once per request. - if config.client_key.algorithm().is_err() { - return Err(refuse( - "the client key must state a supported signing algorithm", - )); - } - let key_id = config - .client_key - .kid - .clone() - .filter(|kid| !kid.trim().is_empty()) - .ok_or_else(|| refuse("the client key must carry a key identifier"))?; - // Stating an algorithm is not the same as being able to sign with it. A - // P-256 scalar of zero and an RSA key whose components disagree are both - // well-formed enough to parse, and are rejected only where the key is - // imported, which is at signing time. Signing once here keeps the promise - // this constructor makes: a key that cannot sign is refused now rather - // than once per request, as an authentication failure that names nothing. - // EdDSA never reaches this, since every 32-byte string is a valid Ed25519 - // seed, which is why signing with EdDSA alone hid the gap. - // - // The probe is deliberately not shaped like a JWS signing input, so the - // signature it discards could not be presented as a client assertion. - let probe = registry_platform_crypto::sign(CLIENT_KEY_PROBE, &config.client_key) - .map_err(|_| refuse("the client key cannot sign a client assertion"))?; - // A JWK carrying one pair's `d` beside another pair's public fields - // produces assertions no server can verify: the public half an adopter - // registers is derived from those fields, so the server would see a - // valid signature over a key it was never given. Verifying the probe - // against this key's own public half is what proves the two belong - // together. - // - // ES256 and ES384 no longer reach this, because importing an EC pair - // compares the two halves and the signing probe above already refused - // the key. EdDSA, RS256, and RS384 import the private half alone and - // still sign happily, so the check stays. - registry_platform_crypto::verify(CLIENT_KEY_PROBE, &probe, &config.client_key.public()) - .map_err(|_| refuse("the client key's halves belong to different key pairs"))?; - // Ties the message below to the constant, so the constant cannot drift - // from the number the message states. - const _: () = assert!(MAXIMUM_ASSERTION_LIFETIME_SECONDS == 300); - if !(1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS).contains(&config.assertion_lifetime_seconds) { - return Err(refuse( - "the assertion lifetime must be within 1..=300 seconds", - )); - } - if config.refresh_margin_seconds < 0 { - return Err(refuse("the refresh margin must not be negative")); - } - if config.request_timeout.is_zero() || config.connect_timeout.is_zero() { - return Err(refuse("the timeouts must be greater than zero")); - } - - let http = outbound::build_client(OutboundOptions { - request_timeout: config.request_timeout, - connect_timeout: config.connect_timeout, - user_agent: config.user_agent.as_deref(), - trusted_root_certificates: config - .trusted_root_certificates - .as_ref() - .map(|pem| pem.as_slice()), - }) - .map_err(refuse)?; - - Ok(Self { - http, - audience: config - .audience - .unwrap_or_else(|| config.token_endpoint.as_str().to_owned()), - token_endpoint: config.token_endpoint, - client_id: config.client_id, - assertion_lifetime_seconds: config.assertion_lifetime_seconds, - refresh_margin_seconds: config.refresh_margin_seconds, - client_key: config.client_key, - key_id, - clock, - cached: RwLock::new(None), - refresh_lock: Mutex::new(()), - }) - } - - /// The cached credential, if it has more life left than the refresh margin. - /// - /// The deadline and `monotonic_now` are both readings of a clock that only - /// moves forward, so a host clock stepping backward cannot present a spent - /// credential as a fresh one. - async fn usable_cached_token(&self, monotonic_now: Instant) -> Option { - // A negative margin was refused at construction, so this is the margin - // the integrator configured. - let margin = Duration::from_secs(self.refresh_margin_seconds.unsigned_abs()); - let cached = self.cached.read().await; - cached - .as_ref() - .filter(|entry| entry.expires_at.saturating_duration_since(monotonic_now) > margin) - .map(|entry| entry.token.clone()) - } - - /// One client assertion, valid from `now` for the configured lifetime. - fn sign_assertion(&self, now: i64) -> Result, TokenError> { - sign_client_assertion( - &self.client_key, - &ClientAssertionRequest { - client_id: &self.client_id, - audience: &self.audience, - lifetime_seconds: self.assertion_lifetime_seconds, - issued_at: now, - }, - ) - .map_err(assertion_refusal) - } - - /// Exchange one fresh assertion for an access token. - /// - /// `now` dates the assertion the authorization server validates, and - /// `monotonic_now` is what the cache deadline of whatever it issues is - /// measured from. - async fn acquire(&self, now: i64, monotonic_now: Instant) -> Result { - let assertion = self.sign_assertion(now)?; - // The assertion is a credential, so it lives in a scrubbed buffer here. - // The body reqwest owns afterwards cannot be wiped, which is why the - // assertion is single use and its lifetime is bounded. - let body = Zeroizing::new( - url::form_urlencoded::Serializer::new(String::new()) - .append_pair("grant_type", GRANT_TYPE) - .append_pair("client_assertion_type", CLIENT_ASSERTION_TYPE) - .append_pair("client_assertion", &assertion) - .finish(), - ); - - let response = self - .http - .post(self.token_endpoint.clone()) - .header(CONTENT_TYPE, FORM_MEDIA_TYPE) - .header(ACCEPT, JSON_MEDIA_TYPE) - .body(body.as_str().to_owned()) - .send() - .await - .map_err(|error| TokenError::Transport { - kind: outbound::send_failure_kind(&error), - })?; - - let status = response.status().as_u16(); - let media_type = response - .headers() - .get(CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); - let body = match read_bounded(response, MAXIMUM_TOKEN_RESPONSE_BYTES).await { - // The response carries a credential, so the buffer it was read into is - // wiped when this exchange ends. - Ok(body) => Zeroizing::new(body), - // The status arrived before the body did, and for a refusal it is the - // whole of what this crate would have reported anyway. - Err(_) if !(200..300).contains(&status) => return Err(TokenError::Protocol { status }), - Err(error) => { - return Err(TokenError::Transport { - kind: outbound::read_failure_kind(&error), - }) - } - }; - - if !(200..300).contains(&status) { - return Err(declined(status, media_type.as_deref(), &body)); - } - if status != 200 - || !media_type - .as_deref() - .is_some_and(|value| essence(value).eq_ignore_ascii_case(JSON_MEDIA_TYPE)) - { - return Err(TokenError::Protocol { status }); - } - let Ok(issued) = serde_json::from_slice::(&body) else { - return Err(TokenError::Protocol { status }); - }; - if !issued.token_type.eq_ignore_ascii_case(BEARER_TOKEN_TYPE) { - return Err(TokenError::Protocol { status }); - } - Ok(AcquiredToken { - // Moved rather than copied, so the credential ends up in the buffer - // `BearerToken` wipes on drop. - token: BearerToken::new(issued.access_token)?, - // A stated lifetime is what makes caching possible. Without one, or - // with one already elapsed, the credential is used once and dropped. - // A lifetime longer than this provider will trust is clamped before - // it ever reaches the cache arithmetic below. - expires_at: issued - .expires_in - .filter(|seconds| *seconds > 0) - .map(|seconds| seconds.min(MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS)) - // What reaches this point is within - // 1..=MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, so the deadline is - // an instant at most a day ahead of the reading it is built on. - .map(|seconds| monotonic_now + Duration::from_secs(seconds.unsigned_abs())), - }) - } -} - -#[async_trait] -impl TokenProvider for PrivateKeyJwt { - async fn bearer_token(&self) -> Result { - if let Some(token) = self.usable_cached_token(self.clock.monotonic()).await { - return Ok(token); - } - // The refresh lock serializes acquisition: one caller holds it and - // performs a token request while the rest wait their turn for it. The - // freshness check below spares a waiter its own request only when the - // caller ahead of it cached something; a credential that could not be - // cached, or a failed request, sends the next waiter to acquire its own - // in turn. A wait is therefore bounded by the number of callers ahead of - // it times the configured request timeout, not by that timeout alone. - let _refreshing = self.refresh_lock.lock().await; - let now = self.clock.unix_seconds(); - let monotonic_now = self.clock.monotonic(); - if let Some(token) = self.usable_cached_token(monotonic_now).await { - return Ok(token); - } - - let acquired = self.acquire(now, monotonic_now).await?; - let mut cached = self.cached.write().await; - // An uncacheable credential clears the cache rather than leaving a stale - // entry behind it. - *cached = acquired.expires_at.map(|expires_at| CachedToken { - token: acquired.token.clone(), - expires_at, - }); - Ok(acquired.token) - } -} - -impl fmt::Debug for PrivateKeyJwt { - /// The client key and the cached credential are withheld. What is rendered is - /// what an operator needs to recognize which provider this is. - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("PrivateKeyJwt") - .field("token_endpoint", &self.token_endpoint.as_str()) - .field("client_id", &self.client_id) - .field("audience", &self.audience) - .field( - "assertion_lifetime_seconds", - &self.assertion_lifetime_seconds, - ) - .field("refresh_margin_seconds", &self.refresh_margin_seconds) - .field("key_id", &self.key_id) - .finish_non_exhaustive() - } -} - -/// A credential and what may be assumed about how long it lasts. -struct AcquiredToken { - token: BearerToken, - expires_at: Option, -} - -/// The success response of RFC 6749 section 5.1, in the members this client uses. -#[derive(Deserialize)] -struct IssuedToken { - access_token: String, - token_type: String, - expires_in: Option, -} - -/// The error response of RFC 6749 section 5.2. -/// -/// Only the code is read. `error_description` and `error_uri` are server-authored -/// text about a failed authentication attempt, so they stay in the buffer this -/// exchange is about to drop. -#[derive(Deserialize)] -struct DeclinedToken { - error: String, -} - -/// Report a refusal from the assertion builder in this provider's own words. -/// -/// Every refusal the builder makes about its inputs was ruled out when the -/// provider was built, and the one it makes about its own output cannot happen -/// for a claim set of strings and numbers, so reaching any of them means a -/// configuration that passed those checks still cannot produce an assertion. It -/// stays an explicit failure rather than a retry, because no later request would -/// sign either. The match is exhaustive so a refusal added to the builder has to -/// be given a reason here, rather than arriving as one the adopter cannot act -/// on. -fn assertion_refusal(error: ClientAssertionError) -> TokenError { - let reason = match error { - ClientAssertionError::EmptyClientId => "the client identifier must not be empty", - ClientAssertionError::EmptyAudience => "the assertion audience must not be empty", - ClientAssertionError::MissingKeyId => "the client key must carry a key identifier", - ClientAssertionError::UnsupportedAlgorithm => { - "the client key must state a supported signing algorithm" - } - ClientAssertionError::LifetimeOutOfRange => { - "the assertion lifetime must be within 1..=300 seconds" - } - ClientAssertionError::NotSerializable => "the client assertion cannot be serialized", - ClientAssertionError::CannotSign => "the client key cannot sign a client assertion", - }; - TokenError::Configuration { reason } -} - -/// Map a refused token request onto the code it reported. -/// -/// RFC 6749 section 5.2 puts a decision about the client at 400, and an -/// authentication failure at 401. Any other status is the server reporting -/// something about itself, which is not a statement this client can act on as a -/// refusal. A body is read as a refusal only when it arrives in the media type -/// the request asked for; an intermediary answering in some other media type, -/// or none at all, never reached the authorization server's own refusal logic, -/// so it is reported as a protocol failure instead. -fn declined(status: u16, media_type: Option<&str>, body: &[u8]) -> TokenError { - if !matches!(status, 400 | 401) - || !media_type.is_some_and(|value| essence(value).eq_ignore_ascii_case(JSON_MEDIA_TYPE)) - { - return TokenError::Protocol { status }; - } - match serde_json::from_slice::(body) { - Ok(declined) => TokenError::Refused { - code: OAuthErrorCode::from_wire(&declined.error), - }, - Err(_) => TokenError::Protocol { status }, - } -} - -#[cfg(test)] -mod tests { - use std::{ - net::TcpListener, - sync::{ - atomic::{AtomicI64, AtomicU64, Ordering}, - Arc, - }, - time::{Duration, Instant}, - }; - - use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; - use ed25519_dalek::SigningKey; - use registry_platform_crypto::{verify, PrivateJwk, PublicJwk}; - use serde_json::{json, Value}; - use url::Url; - use wiremock::{ - matchers::{body_string_contains, header, method, path}, - Mock, MockServer, ResponseTemplate, - }; - - use super::*; - use crate::{ - error::TransportKind, - token::{OAuthErrorCode, TokenError, TokenProvider}, - }; - - /// The instant the offline assertions in this module are centered on. - const NOW: i64 = 1_785_000_000; - const CLIENT_ID: &str = "urn:example:client:relying-party"; - const KEY_ID: &str = "client-key-2026-01"; - const TOKEN_PATH: &str = "/token"; - /// A credential text no server in this module ever varies, so a test can - /// assert on which credential a caller received. - const ISSUED_CREDENTIAL: &str = "issued-access-token"; - const TOKEN_LIFETIME_SECONDS: i64 = 300; - - /// A clock a test moves by hand, so cache arithmetic is asserted rather than - /// waited out. - /// - /// Both readings move together under [`TestClock::set`], as they do on a host - /// whose clock nothing is correcting. A test that needs them to disagree, - /// which is what a correction looks like, moves the wall reading on its own. - struct TestClock { - unix_seconds: AtomicI64, - unix_origin: i64, - monotonic_origin: Instant, - monotonic_elapsed_seconds: AtomicU64, - } - - impl TestClock { - fn new(now: i64) -> Self { - Self { - unix_seconds: AtomicI64::new(now), - unix_origin: now, - monotonic_origin: Instant::now(), - monotonic_elapsed_seconds: AtomicU64::new(0), - } - } - - /// Both readings are now at `now`, which is what time passing looks like. - fn set(&self, now: i64) { - self.unix_seconds.store(now, Ordering::Relaxed); - let elapsed = u64::try_from(now - self.unix_origin) - .expect("a test moves this clock forward from where it started"); - self.monotonic_elapsed_seconds - .store(elapsed, Ordering::Relaxed); - } - - /// Move the wall reading back, leaving the monotonic reading where it is. - /// That is what an NTP correction, a virtual machine resume, or an - /// operator setting the clock by hand does to a running process. - fn step_wall_clock_backward(&self, seconds: i64) { - self.unix_seconds.fetch_sub(seconds, Ordering::Relaxed); - } - } - - impl Clock for TestClock { - fn unix_seconds(&self) -> i64 { - self.unix_seconds.load(Ordering::Relaxed) - } - - fn monotonic(&self) -> Instant { - self.monotonic_origin - + Duration::from_secs(self.monotonic_elapsed_seconds.load(Ordering::Relaxed)) - } - } - - /// A fresh EdDSA client key, generated here so no test carries key material - /// in the tree. - fn client_key(key_id: Option<&str>) -> PrivateJwk { - let mut seed = [0u8; 32]; - getrandom::fill(&mut seed).expect("the test host supplies randomness"); - let key = SigningKey::from_bytes(&seed); - let mut document = json!({ - "kty": "OKP", - "crv": "Ed25519", - "alg": "EdDSA", - "x": URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes()), - "d": URL_SAFE_NO_PAD.encode(key.to_bytes()), - }); - if let Some(key_id) = key_id { - document["kid"] = json!(key_id); - } - PrivateJwk::parse(&document.to_string()).expect("the test key parses") - } - - /// A fresh ES256 client key, in the shape `evidencectl access client add - /// --generate-local-key` writes, which is what an adopter following the - /// tutorial actually holds. - fn es256_client_key(key_id: Option<&str>) -> PrivateJwk { - let key = p256::ecdsa::SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng); - let point = key.verifying_key().to_encoded_point(false); - let mut document = json!({ - "kty": "EC", - "crv": "P-256", - "alg": "ES256", - "x": URL_SAFE_NO_PAD.encode(point.x().expect("an uncompressed P-256 point has x")), - "y": URL_SAFE_NO_PAD.encode(point.y().expect("an uncompressed P-256 point has y")), - "d": URL_SAFE_NO_PAD.encode(key.to_bytes()), - }); - if let Some(key_id) = key_id { - document["kid"] = json!(key_id); - } - PrivateJwk::parse(&document.to_string()).expect("the test key parses") - } - - /// A test-only 2048-bit RSA client key. RSA keys are too slow to generate per - /// test and the workspace carries no RSA generator, so unlike the EdDSA and - /// ES256 keys above this one is a constant. It is the same test-only key - /// `registry-platform-crypto` already pins for its RS256 tests, so it puts no - /// new key material in the tree. It authenticates nothing. - const RSA_CLIENT_JWK: &str = r#"{"kty":"RSA","kid":"registry-evidence-client-rs256-test","alg":"RS256","n":"yIgEn3IXWI3CRyUY0gvZ-kJ55EC36MRFvj-ICsitN1-50phRS4CKMBRwbHwjgeTkbMDndOCmVfIbyKhJjOMIPxAzIHeMn9oWj5i-s8nlSgjHZpvCTnRbwZhbq6mEVoHJliX36IfV_iUopcwSL5lPd2wZmJ-msUmZFs6CTRExu0JGUJScOwFO5dqxBwiKyh7yGEPXI3u4tc3_47SZYxyde7fb-o3wl2RBJ28upa2jVRP9r-WjOGjE6tbZ35HnVUY4ECdYWzsiotg_XA9QVWa-pAKXV2Flr-gocCQ9E2qrSYjEbNXuFjPtMnuL6AHi0o5PiwT1dllcl925hpKd7Xt60w","e":"AQAB","d":"ATDtMhpe_z1-GTUV7NLO3V_Z0kb8W1YXkC7JbJTAdcE-FdKJrtu84Q87WpxG0tPcutFPLqW12QAQp2fbmxhZ6VrfVYneeOlEjO14ukqM_g35Z-eRDmYhwoFYrEWGqlH9XrZysHhKFZyKHW_G0lJV-Ks8Na_RFNNIXeVedVMQiytAFXibTHvdAdIrBGtt0M4tlQOCeRwnuoAQU-a5VB7rKGpxnJtUA7F_jjeX6jQPnUhkOXs20pPRey-i-jxwBbsF4XijHgTnGwAo5uOoY9b0kOmOb3Hs5TVqZCb3a4JoYAqZBbWrkKxccJTGMqLHCe0MBgQzKqP5KyrHRgQdzlmTnQ","p":"5xhkHe5lD7tUYJAFffHiRpy4unHfKDvTEASu8RBgWvHP2Hu5XLQU5n6DvI47LsW42swTcT6Ce1pWB2LK3SjKcw9FPEEGg8m5-tmfixaRq4DBaK0hj17763HmnYR0eQC0n_5y-My8WSC1y80T-AhKHJ_3xTtLXQd5Z9bf9MEiKS8","q":"3iRoiwbnn8oRJMjZUZhqKB-GVa7AJV0SUqXiUsBAJnqtbhuIESbkJKpt5eULeUQgdNkoG65KD-jXFUipWX1zlentc1FliCaB46jntqtxUsui8LNwKw_eb3nujQO7H1He4NJ5pfaLfRcmBOLwB-u2Z1cxrRDWhIgiHtGaAdQ7F50","dp":"j4h9vn1wNbozaRpq3tPap-L1dY_-e93UdPGDuuRiBHqGjr4h3itXg-X2aqmopp9V9kekl8SshHMSVdoNiBmqzJYieY8lvbsQkXaTem8VIQGCn0JRQtxK-eyvwQwgz3sZtPn0bQW0wmLnp2KD0Z1McsUEvnLalzhqNo2mYj2Guy8","dq":"0T6ySuLCIz2PUHrwWW-b7xdizirBS3CT5c3jldcJljVQT7sXPDDKDc-LnVVWrW-Csw4qPYi6sqm8j4vWGTmWOswSouE1Jj4_c1aSjPqI0FiIrvoW2jkkaRUNoz60cBgKPPOFKtNFKRs48LljJ9LcChOT81U8-7HPkgAVdUuYLfE","qi":"PnMeCE0dvWDLp2Dn1wsxtl-a0qjpkT9cp8EkvHYjCvVqqWqrVv84CoEo-1wA9j_VDvCG6T4n0UO9K0jfBf5yvPnahSQCLJk2nw-2uZ9YzBZKwkm21wU6hTknPst5Vk5ZbYJmzqXsCqEB5T2Bn5vqeXMe3SOB5hD2CbTFFfp3TC4"}"#; - - fn rs256_client_key() -> PrivateJwk { - PrivateJwk::parse(RSA_CLIENT_JWK).expect("the test key parses") - } - - /// A test-only P-384 client key. It restates the key material - /// `registry-platform-authcommon` and `registry-platform-crypto` already pin - /// for their own ES384 tests under a client `kid`, so it puts no new key - /// material in the tree. It authenticates nothing. - const P384_CLIENT_JWK: &str = r#"{"kty":"EC","crv":"P-384","d":"Cp2oq8BnIF6oQ2KWV-1yiR7Mf0rFOuDZ5nvS9E_9HGEODI76izZiDEFQ5kfSwCAg","x":"TH-XDvwYtzdc43QDOiBjfdQZTCx1k9Rz5ELDu_2NS8JWcCv8HlfK0T9rYijDIcAY","y":"eLx0gh3VmCC2DeubmC0CdDgno7aEBYEkz5Legyg-2GoLlFohSIop3zKCGSjhg7Ta","alg":"ES384","kid":"client-key-es384-2026-01"}"#; - - fn es384_client_key() -> PrivateJwk { - PrivateJwk::parse(P384_CLIENT_JWK).expect("the test key parses") - } - - /// The pinned RSA key restated under `alg`, which is the only difference - /// between an RS256 and an RS384 RSA JWK. - fn rs384_client_key() -> PrivateJwk { - let mut key = rs256_client_key(); - key.alg = Some("RS384".to_owned()); - key - } - - /// An ES256 key that parses and states its algorithm, yet cannot sign: zero - /// is a well-formed 32-byte scalar and an invalid P-256 private key. - /// - /// There is no EdDSA counterpart, because every 32-byte string is a valid - /// Ed25519 seed. That asymmetry is why signing with EdDSA alone never - /// exposed the gap this case covers. - fn unsignable_es256_client_key() -> PrivateJwk { - let mut key = es256_client_key(Some(KEY_ID)); - key.d = Some(URL_SAFE_NO_PAD.encode([0u8; 32])); - key - } - - /// An RS256 key that parses and states its algorithm, yet cannot sign: each - /// component is well-formed on its own, but `p` no longer divides `n`. - fn unsignable_rs256_client_key() -> PrivateJwk { - let mut key = rs256_client_key(); - key.p = key.q.clone(); - key - } - - /// An EdDSA key whose two halves belong to different key pairs. It signs, - /// and nothing it signs verifies against the public half an adopter would - /// register from this same document. - fn mismatched_eddsa_client_key() -> PrivateJwk { - let mut key = client_key(Some(KEY_ID)); - key.x = client_key(None).x.clone(); - key - } - - /// The ES256 counterpart: `d` from one pair, `x` and `y` from another. - /// - /// Unlike its EdDSA sibling this one never signs, since importing a P-256 - /// pair compares the halves. It is refused as a key that cannot sign rather - /// than as one whose probe fails to verify. - fn mismatched_es256_client_key() -> PrivateJwk { - let mut key = es256_client_key(Some(KEY_ID)); - let other = es256_client_key(None); - key.x = other.x.clone(); - key.y = other.y.clone(); - key - } - - fn endpoint(base: &str) -> Url { - format!("{base}{TOKEN_PATH}") - .parse() - .expect("the token endpoint parses") - } - - fn config(token_endpoint: Url, client_key: PrivateJwk) -> PrivateKeyJwtConfig { - PrivateKeyJwtConfig::new(token_endpoint, CLIENT_ID, client_key) - } - - /// A provider on a test clock, against a token endpoint that answers with one - /// credential. - fn provider(token_endpoint: Url, clock: &Arc) -> PrivateKeyJwt { - PrivateKeyJwt::with_clock( - config(token_endpoint, client_key(Some(KEY_ID))), - clock.clone(), - ) - .expect("the provider is usable as configured") - } - - /// The token response a compliant authorization server returns. - fn issued(expires_in: Option) -> ResponseTemplate { - let mut body = json!({ - "access_token": ISSUED_CREDENTIAL, - "token_type": "Bearer", - }); - if let Some(expires_in) = expires_in { - body["expires_in"] = json!(expires_in); - } - ResponseTemplate::new(200).set_body_json(body) - } - - async fn token_endpoint_serving(response: ResponseTemplate) -> MockServer { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(TOKEN_PATH)) - .and(header("content-type", "application/x-www-form-urlencoded")) - .respond_with(response) - .mount(&server) - .await; - server - } - - async fn token_requests(server: &MockServer) -> usize { - server - .received_requests() - .await - .expect("the mock server records its requests") - .len() - } - - fn parts(assertion: &str) -> (Value, Value, Vec) { - let segments: Vec<&str> = assertion.split('.').collect(); - assert_eq!(segments.len(), 3, "an assertion carries three segments"); - let decode = |segment: &str| { - let bytes = URL_SAFE_NO_PAD - .decode(segment) - .expect("the segment is base64url"); - serde_json::from_slice::(&bytes).expect("the segment carries JSON") - }; - let signature = URL_SAFE_NO_PAD - .decode(segments[2]) - .expect("the signature is base64url"); - (decode(segments[0]), decode(segments[1]), signature) - } - - fn signing_input(assertion: &str) -> &str { - let boundary = assertion - .rfind('.') - .expect("an assertion carries three segments"); - &assertion[..boundary] - } - - /// RFC 7523 section 2.2 fixes the claim set the token endpoint reads. The - /// header names the key so the server can select it without guessing. - #[test] - fn an_assertion_carries_the_claims_the_token_endpoint_requires() { - let key = client_key(Some(KEY_ID)); - let public: PublicJwk = key.public(); - let secret = key - .d - .clone() - .expect("the test key carries private material"); - let token_endpoint = endpoint("https://tokens.example.org"); - let clock = Arc::new(TestClock::new(NOW)); - let provider = - PrivateKeyJwt::with_clock(config(token_endpoint.clone(), key), clock.clone()) - .expect("the provider is usable as configured"); - - let assertion = provider - .sign_assertion(NOW) - .expect("the assertion is signed"); - let (header, claims, signature) = parts(&assertion); - - assert_eq!(header, json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_ID})); - assert_eq!(claims["iss"], json!(CLIENT_ID)); - assert_eq!(claims["sub"], json!(CLIENT_ID)); - assert_eq!(claims["aud"], json!(token_endpoint.as_str())); - assert_eq!(claims["iat"], json!(NOW)); - assert_eq!( - claims["exp"], - json!(NOW + DEFAULT_ASSERTION_LIFETIME_SECONDS) - ); - assert_eq!( - claims["jti"] - .as_str() - .expect("the assertion carries a jti") - .len(), - 26, - "the jti is a ULID" - ); - let members: std::collections::BTreeSet<&str> = claims - .as_object() - .expect("the claims are an object") - .keys() - .map(String::as_str) - .collect(); - assert_eq!( - members, - ["aud", "exp", "iat", "iss", "jti", "sub"] - .into_iter() - .collect(), - "the assertion carries exactly the claims the profile fixes" - ); - verify(signing_input(&assertion).as_bytes(), &signature, &public) - .expect("the assertion verifies under the client key"); - assert!( - !assertion.contains(&secret), - "the assertion carries the private key" - ); - } - - /// The header must name the algorithm the key actually signs with, for every - /// algorithm the stack registers. A server selects the verification algorithm - /// from this header, so a fixed `alg` would either refuse the key outright or - /// present a signature under a name that does not describe it. - /// - /// ES256 is the case an adopter meets first: it is what `evidencectl` writes - /// for a locally generated client key. - #[test] - fn an_assertion_names_the_algorithm_the_client_key_states() { - for (expected_alg, key) in [ - ("EdDSA", client_key(Some(KEY_ID))), - ("ES256", es256_client_key(Some(KEY_ID))), - ("RS256", rs256_client_key()), - ("ES384", es384_client_key()), - ("RS384", rs384_client_key()), - ] { - let key_id = key.kid.clone().expect("the test key carries a kid"); - let public: PublicJwk = key.public(); - let clock = Arc::new(TestClock::new(NOW)); - let provider = PrivateKeyJwt::with_clock( - config(endpoint("https://tokens.example.org"), key), - clock, - ) - .unwrap_or_else(|error| { - panic!("a {expected_alg} client key is usable as configured: {error}") - }); - - let assertion = provider - .sign_assertion(NOW) - .unwrap_or_else(|error| panic!("the {expected_alg} assertion is signed: {error}")); - let (header, _, signature) = parts(&assertion); - - assert_eq!( - header, - json!({"alg": expected_alg, "typ": "JWT", "kid": key_id}) - ); - verify(signing_input(&assertion).as_bytes(), &signature, &public).unwrap_or_else( - |error| { - panic!("the {expected_alg} assertion verifies under the client key: {error}") - }, - ); - } - } - - /// A replay-checking token endpoint refuses a repeated `jti`, so a fresh one - /// per request is what makes a second token request possible at all. - #[test] - fn every_assertion_gets_its_own_jti() { - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint("https://tokens.example.org"), &clock); - - let first = provider - .sign_assertion(NOW) - .expect("the assertion is signed"); - let second = provider - .sign_assertion(NOW) - .expect("the assertion is signed"); - - let (_, first_claims, _) = parts(&first); - let (_, second_claims, _) = parts(&second); - assert_ne!(first_claims["jti"], second_claims["jti"]); - assert_eq!(first_claims["iat"], second_claims["iat"]); - } - - /// A deployment whose token endpoint expects an audience of its own name says - /// so, and the default is the endpoint URL. - #[test] - fn the_assertion_audience_can_be_overridden() { - let clock = Arc::new(TestClock::new(NOW)); - let provider = PrivateKeyJwt::with_clock( - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_audience("https://tokens.example.org/"), - clock.clone(), - ) - .expect("the provider is usable as configured"); - - let assertion = provider - .sign_assertion(NOW) - .expect("the assertion is signed"); - let (_, claims, _) = parts(&assertion); - assert_eq!(claims["aud"], json!("https://tokens.example.org/")); - } - - /// The request the token endpoint receives is the form-encoded grant the - /// profile fixes, and it carries the assertion rather than a secret. - #[tokio::test] - async fn the_token_request_states_the_grant_and_the_authentication_method() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path(TOKEN_PATH)) - .and(header("content-type", "application/x-www-form-urlencoded")) - .and(header("accept", "application/json")) - .and(body_string_contains("grant_type=client_credentials")) - .and(body_string_contains( - "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer", - )) - .and(body_string_contains("client_assertion=")) - .respond_with(issued(Some(TOKEN_LIFETIME_SECONDS))) - .mount(&server) - .await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); - - let token = provider - .bearer_token() - .await - .expect("the token endpoint issued a credential"); - - assert_eq!(token.expose(), ISSUED_CREDENTIAL); - assert_eq!(token_requests(&server).await, 1); - } - - /// A credential is reused while it has more life left than the refresh margin, - /// and a caller arriving inside the margin gets a fresh one instead of a - /// credential that may expire in flight. - #[tokio::test] - async fn a_cached_credential_is_reused_until_the_refresh_margin() { - let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); - - provider.bearer_token().await.expect("a first credential"); - assert_eq!(token_requests(&server).await, 1); - - // Well inside the cached lifetime. - clock.set(NOW + TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS - 1); - provider - .bearer_token() - .await - .expect("the cached credential"); - assert_eq!( - token_requests(&server).await, - 1, - "a usable cached credential was discarded" - ); - - // The first instant inside the refresh margin. - clock.set(NOW + TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS); - provider - .bearer_token() - .await - .expect("a replacement credential"); - assert_eq!( - token_requests(&server).await, - 2, - "a credential inside the refresh margin was reused" - ); - } - - /// A host clock steps backward for ordinary reasons: an NTP correction, a - /// virtual machine resuming, an operator setting it by hand. A credential - /// whose deadline was a wall-clock time would look fresh again for as long as - /// the step was wide, and the provider would keep presenting a credential the - /// authorization server has already expired. - #[tokio::test] - async fn a_cached_credential_is_not_reused_after_the_host_clock_steps_backward() { - let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); - - provider.bearer_token().await.expect("a first credential"); - assert_eq!(token_requests(&server).await, 1); - - // The whole stated lifetime really elapsed, so the credential is spent. - clock.set(NOW + TOKEN_LIFETIME_SECONDS); - // Then the host clock is corrected far enough backward that its reading - // is before the credential was issued at all. - clock.step_wall_clock_backward(TOKEN_LIFETIME_SECONDS * 2); - assert!( - clock.unix_seconds() < NOW, - "the correction lands before the credential was issued" - ); - - provider - .bearer_token() - .await - .expect("a replacement credential"); - assert_eq!( - token_requests(&server).await, - 2, - "a spent credential was replayed after the host clock stepped backward" - ); - } - - /// A stated lifetime the issuer never bounded, such as `i64::MAX`, must not - /// keep a credential cached for the life of the process. The provider clamps - /// it to its own configured maximum before caching. - #[tokio::test] - async fn an_unbounded_stated_lifetime_is_clamped_to_the_configured_maximum() { - let server = token_endpoint_serving(issued(Some(i64::MAX))).await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); - - provider.bearer_token().await.expect("a first credential"); - assert_eq!(token_requests(&server).await, 1); - - // Well inside the clamped lifetime, despite the issuer stating an - // effectively unbounded one. - clock.set(NOW + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS - 1); - provider - .bearer_token() - .await - .expect("the cached credential"); - assert_eq!( - token_requests(&server).await, - 1, - "a usable cached credential was discarded" - ); - - // The first instant inside the refresh margin of the clamped lifetime. - clock.set(NOW + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS); - provider - .bearer_token() - .await - .expect("a replacement credential"); - assert_eq!( - token_requests(&server).await, - 2, - "an unbounded stated lifetime was cached past the configured maximum" - ); - } - - /// A server that states no lifetime, or one that is already zero or - /// negative, has told the client nothing it may cache against, so every - /// request acquires its own credential rather than writing an unusable one - /// into the cache. - #[tokio::test] - async fn a_credential_without_a_stated_lifetime_is_not_cached() { - for expires_in in [None, Some(0), Some(-1)] { - let server = token_endpoint_serving(issued(expires_in)).await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); - - provider.bearer_token().await.expect("a first credential"); - provider.bearer_token().await.expect("a second credential"); - - assert_eq!( - token_requests(&server).await, - 2, - "expires_in {expires_in:?} was cached" - ); - } - } - - /// Many callers starting at once must not each open a token request. The - /// first one performs it and the rest use what it cached. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn concurrent_callers_make_one_token_request() { - let server = token_endpoint_serving( - issued(Some(TOKEN_LIFETIME_SECONDS)).set_delay(Duration::from_millis(50)), - ) - .await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = Arc::new(provider(endpoint(&server.uri()), &clock)); - - let callers: Vec<_> = (0..20) - .map(|_| { - let provider = provider.clone(); - tokio::spawn(async move { provider.bearer_token().await }) - }) - .collect(); - for caller in callers { - let token = caller - .await - .expect("the caller task ran") - .expect("every caller received a credential"); - assert_eq!(token.expose(), ISSUED_CREDENTIAL); - } - - assert_eq!( - token_requests(&server).await, - 1, - "concurrent callers stampeded the token endpoint" - ); - } - - /// Tokio's asynchronous mutex is not poisoned when a guard is dropped - /// mid-await, unlike `std::sync::Mutex`. A caller abandoned while it holds - /// the refresh lock, whether by cancellation or a panic elsewhere in the - /// same task, must still let the next caller acquire the lock and receive - /// a credential rather than waiting on a lock nothing will ever release. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn a_dropped_acquisition_releases_the_refresh_lock_for_the_next_caller() { - let server = token_endpoint_serving( - issued(Some(TOKEN_LIFETIME_SECONDS)).set_delay(Duration::from_millis(200)), - ) - .await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = Arc::new(provider(endpoint(&server.uri()), &clock)); - - let abandoned = { - let provider = provider.clone(); - tokio::spawn(async move { provider.bearer_token().await }) - }; - tokio::time::sleep(Duration::from_millis(50)).await; - abandoned.abort(); - let _ = abandoned.await; - - let token = tokio::time::timeout(Duration::from_secs(2), provider.bearer_token()) - .await - .expect("the refresh lock was not left held by the abandoned acquisition") - .expect("a subsequent caller still receives a credential"); - assert_eq!(token.expose(), ISSUED_CREDENTIAL); - // The abandoned task's own request reaching the server proves it was - // past the lock acquisition, and the response delay of four times the - // abort delay proves it was still awaiting that request, so still - // holding the lock, when the abort landed. - assert_eq!( - token_requests(&server).await, - 2, - "the abandoned task never reached the token request it would have held the lock for" - ); - } - - /// A declined request reports the registered code and nothing else. The - /// description is server-authored text about a failed authentication, so it - /// must not reach the caller's diagnostic. - #[tokio::test] - async fn a_declined_token_request_reports_only_the_registered_code() { - let cases = [ - ( - 400, - json!({"error": "invalid_request"}).to_string(), - JSON_MEDIA_TYPE, - TokenError::Refused { - code: OAuthErrorCode::InvalidRequest, - }, - ), - ( - 401, - json!({"error": "invalid_client", "error_description": "canary assertion detail"}) - .to_string(), - JSON_MEDIA_TYPE, - TokenError::Refused { - code: OAuthErrorCode::InvalidClient, - }, - ), - ( - 400, - json!({"error": "canary_extension_code"}).to_string(), - JSON_MEDIA_TYPE, - TokenError::Refused { - code: OAuthErrorCode::Other, - }, - ), - ( - 400, - "canary not json at all".to_owned(), - JSON_MEDIA_TYPE, - TokenError::Protocol { status: 400 }, - ), - ( - 403, - json!({"error": "invalid_client"}).to_string(), - JSON_MEDIA_TYPE, - TokenError::Protocol { status: 403 }, - ), - ( - 500, - json!({"error": "server_error"}).to_string(), - JSON_MEDIA_TYPE, - TokenError::Protocol { status: 500 }, - ), - // A real authorization server states a charset parameter on its - // JSON responses; the essence the gate compares against must - // still match with one present. - ( - 400, - json!({"error": "invalid_request"}).to_string(), - "application/json; charset=utf-8", - TokenError::Refused { - code: OAuthErrorCode::InvalidRequest, - }, - ), - ]; - - for (status, body, media_type, expected) in cases { - let server = token_endpoint_serving( - ResponseTemplate::new(status).set_body_raw(body.clone(), media_type), - ) - .await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); - - let error = provider - .bearer_token() - .await - .expect_err("the token endpoint declined"); - assert_eq!(error, expected, "status {status}"); - let rendered = error.to_string(); - assert!(!rendered.contains("canary"), "{rendered}"); - } - } - - /// A 400 or 401 body is read as a refusal only when it is announced in the - /// media type the request asked for. An intermediary that answers in a - /// different media type, or none at all, never reached the authorization - /// server's own refusal logic, so it must be reported as a protocol failure - /// rather than as a refusal the adopter cannot act on. - #[tokio::test] - async fn a_declined_status_in_the_wrong_media_type_is_a_protocol_failure() { - let refusal = json!({"error": "invalid_request"}).to_string(); - let cases = [ - ( - 400, - ResponseTemplate::new(400).set_body_bytes(refusal.clone()), - "absent content type", - ), - ( - 401, - ResponseTemplate::new(401).set_body_raw(refusal.clone(), "text/plain"), - "wrong content type", - ), - ]; - - for (status, response, label) in cases { - let server = token_endpoint_serving(response).await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); - - let error = provider - .bearer_token() - .await - .expect_err("the answer is not a usable refusal"); - assert_eq!(error, TokenError::Protocol { status }, "{label}"); - } - } - - /// An answer that is not a usable token response is a protocol failure, never - /// a credential. Each of these would otherwise become a request the deployment - /// refuses for a reason the adopter cannot see. - #[tokio::test] - async fn an_unusable_token_response_is_refused() { - let cases = [ - ( - "a success in the wrong media type", - ResponseTemplate::new(200) - .insert_header("content-type", "text/plain") - .set_body_string( - json!({"access_token": "canary", "token_type": "Bearer"}).to_string(), - ), - ), - ( - "a success carrying no credential", - ResponseTemplate::new(200).set_body_json(json!({"token_type": "Bearer"})), - ), - ( - "a credential the Evidence request cannot present", - ResponseTemplate::new(200).set_body_json( - json!({"access_token": "canary", "token_type": "mac", "expires_in": 300}), - ), - ), - ( - "a credential that is not header safe", - ResponseTemplate::new(200) - .set_body_json(json!({"access_token": "canary token", "token_type": "Bearer"})), - ), - ( - "an unreadable success body", - ResponseTemplate::new(200).set_body_raw("{", "application/json"), - ), - ( - "a redirect instead of an answer", - ResponseTemplate::new(302).insert_header("location", "https://elsewhere.invalid/"), - ), - ]; - - for (description, response) in cases { - let server = token_endpoint_serving(response).await; - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&server.uri()), &clock); - - let error = provider - .bearer_token() - .await - .expect_err("the answer is not a usable token response"); - let rendered = error.to_string(); - assert!(!rendered.contains("canary"), "{description}: {rendered}"); - assert!( - matches!( - error, - TokenError::Protocol { .. } | TokenError::Invalid { .. } - ), - "{description}: {error:?}" - ); - } - } - - /// A token endpoint that never answers is reported as a transport failure, so - /// a caller can tell an unreachable server from a refusal. - #[tokio::test] - async fn a_token_endpoint_that_cannot_be_reached_reports_a_transport_failure() { - // The port is reserved and released, so the connection attempt is refused - // rather than answered. - let reservation = - TcpListener::bind(("127.0.0.1", 0)).expect("a loopback port is available"); - let port = reservation - .local_addr() - .expect("the reservation has an address") - .port(); - drop(reservation); - let clock = Arc::new(TestClock::new(NOW)); - let provider = provider(endpoint(&format!("http://127.0.0.1:{port}")), &clock); - - let error = provider - .bearer_token() - .await - .expect_err("nothing is listening"); - assert_eq!( - error, - TokenError::Transport { - kind: TransportKind::Connect - } - ); - } - - /// A provider that could not authenticate, could not protect its assertion in - /// transit, or could not sign at all fails at construction rather than once - /// per request. - #[test] - fn an_unusable_provider_configuration_is_refused() { - let cases: Vec<(&str, PrivateKeyJwtConfig)> = vec![ - ( - "the client identifier must not be empty", - PrivateKeyJwtConfig::new( - endpoint("https://tokens.example.org"), - " ", - client_key(Some(KEY_ID)), - ), - ), - ( - "the token endpoint must use HTTPS, or HTTP with a loopback host", - config( - endpoint("http://tokens.example.org"), - client_key(Some(KEY_ID)), - ), - ), - ( - "the token endpoint must carry no credentials or fragment", - config( - "https://client:canary@tokens.example.org/token" - .parse() - .expect("the endpoint parses"), - client_key(Some(KEY_ID)), - ), - ), - ( - "the token endpoint must carry no credentials or fragment", - config( - "https://tokens.example.org/token#canary" - .parse() - .expect("the endpoint parses"), - client_key(Some(KEY_ID)), - ), - ), - ( - "the assertion audience must not be empty", - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_audience(""), - ), - ( - "the assertion audience must not be empty", - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_audience(" "), - ), - ( - "the client key must carry a key identifier", - config(endpoint("https://tokens.example.org"), client_key(None)), - ), - ( - "the client key must carry a key identifier", - config(endpoint("https://tokens.example.org"), { - let mut key = es256_client_key(None); - key.kid = None; - key - }), - ), - ( - "the assertion lifetime must be within 1..=300 seconds", - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_assertion_lifetime_seconds(0), - ), - ( - "the assertion lifetime must be within 1..=300 seconds", - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_assertion_lifetime_seconds(MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1), - ), - ( - "the refresh margin must not be negative", - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_refresh_margin_seconds(-1), - ), - ( - "the timeouts must be greater than zero", - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_request_timeout(Duration::ZERO), - ), - ( - "the client key cannot sign a client assertion", - config( - endpoint("https://tokens.example.org"), - unsignable_es256_client_key(), - ), - ), - ( - "the client key cannot sign a client assertion", - config( - endpoint("https://tokens.example.org"), - unsignable_rs256_client_key(), - ), - ), - ( - "the client key's halves belong to different key pairs", - config( - endpoint("https://tokens.example.org"), - mismatched_eddsa_client_key(), - ), - ), - ( - "the client key cannot sign a client assertion", - config( - endpoint("https://tokens.example.org"), - mismatched_es256_client_key(), - ), - ), - ( - "the pinned certificate authority bundle carries no certificate", - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_trusted_root_certificates(Vec::new()), - ), - ( - "the pinned certificate authority bundle is not readable PEM", - config( - endpoint("https://tokens.example.org"), - client_key(Some(KEY_ID)), - ) - .with_trusted_root_certificates( - b"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n".to_vec(), - ), - ), - ]; - - for (reason, candidate) in cases { - let error = PrivateKeyJwt::new(candidate).expect_err(reason); - assert_eq!(error, TokenError::Configuration { reason }); - } - } - - /// A key, a cached credential, and an assertion are all secrets. None of them - /// may reach a rendering. - #[tokio::test] - async fn debug_output_never_carries_the_client_key_or_the_credential() { - let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; - let key = client_key(Some(KEY_ID)); - let secret = key - .d - .clone() - .expect("the test key carries private material"); - let candidate = config(endpoint(&server.uri()), key); - let rendered = format!("{candidate:?}"); - assert!(!rendered.contains(&secret), "{rendered}"); - - let clock = Arc::new(TestClock::new(NOW)); - let provider = PrivateKeyJwt::with_clock(candidate, clock.clone()) - .expect("the provider is usable as configured"); - provider.bearer_token().await.expect("a credential"); - let rendered = format!("{provider:?}"); - assert!(!rendered.contains(&secret), "{rendered}"); - assert!(!rendered.contains(ISSUED_CREDENTIAL), "{rendered}"); - assert!(rendered.contains(KEY_ID), "the key identifier is public"); - } - - /// A caller can put userinfo in the token endpoint it names, and a rendering - /// that carried it through would print that credential wherever the - /// configuration is rendered: a wider `Debug`, a panic message, a tracing - /// field. - #[test] - fn debug_output_withholds_userinfo_the_caller_put_in_the_token_endpoint() { - let candidate = config( - endpoint("https://client:s3cr3t@issuer.example.org"), - client_key(Some(KEY_ID)), - ); - - let rendered = format!("{candidate:?}"); - - assert!(!rendered.contains("s3cr3t"), "{rendered}"); - // The separator is what makes a userinfo component one, and no other - // field of this rendering carries it, so its absence is what proves none - // was rendered under any spelling. - assert!(!rendered.contains('@'), "{rendered}"); - // The endpoint still has to be recognizable, or the rendering is no use - // for telling one misconfigured deployment from another. - assert!(rendered.contains("issuer.example.org/token"), "{rendered}"); - } -} diff --git a/crates/registry-evidence-client/src/problem.rs b/crates/registry-evidence-client/src/problem.rs index 2c8bb97f8..6bdefdb22 100644 --- a/crates/registry-evidence-client/src/problem.rs +++ b/crates/registry-evidence-client/src/problem.rs @@ -5,11 +5,12 @@ //! must agree with the response's `traceparent`; otherwise neither correlation //! nor the problem classification is trustworthy. -use serde::Deserialize; +use registry_platform_httpsec::ProblemDocument; use crate::error::EvidenceClientError; pub(crate) const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; +#[cfg(test)] pub(crate) const TRACEPARENT_HEADER: &str = "traceparent"; const PROBLEM_TYPE_PREFIX: &str = "https://id.registrystack.org/problems/registry-evidence/"; const EVIDENCE_NOT_AVAILABLE: &str = "evidence.unavailable"; @@ -80,19 +81,6 @@ const REGISTERED_PROBLEMS: [(u16, &str, &str, &str); 10] = [ ), ]; -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -#[serde(deny_unknown_fields)] -struct ProblemBody { - #[serde(rename = "type")] - type_uri: String, - title: String, - status: u16, - detail: String, - code: String, - #[serde(rename = "traceId")] - trace_id: String, -} - /// Map a refused or failed exchange onto one coarse client failure. /// /// `Retry-After` is actionable only for a registered 429 response. The caller @@ -121,11 +109,11 @@ pub(crate) fn map_problem( (401 | 403 | 429, Some(code)) => EvidenceClientError::Denied { status, code: code.to_owned(), - trace_id: Some(problem.trace_id), + trace_id: Some(problem.trace_id.as_str().to_owned()), retry_after_seconds: retry_after_seconds.filter(|_| status == 429), }, (422, Some(EVIDENCE_NOT_AVAILABLE)) => EvidenceClientError::NotAvailable { - trace_id: Some(problem.trace_id), + trace_id: Some(problem.trace_id.as_str().to_owned()), }, (_, code) => protocol(status, header_trace_id, code.map(str::to_owned)), } @@ -141,23 +129,22 @@ fn protocol(status: u16, trace_id: Option<&str>, code: Option) -> Eviden } /// Parse an exact six-member problem body, including its registered type URI. -fn parse_problem(media_type: Option<&str>, body: &[u8]) -> Option { +fn parse_problem(media_type: Option<&str>, body: &[u8]) -> Option { if !media_type.is_some_and(|value| essence(value).eq_ignore_ascii_case(PROBLEM_MEDIA_TYPE)) || body.is_empty() || body.len() > MAXIMUM_PROBLEM_BYTES { return None; } - let problem: ProblemBody = serde_json::from_slice(body).ok()?; - if !is_canonical_trace_id(&problem.trace_id) - || !REGISTERED_PROBLEMS - .iter() - .any(|(status, code, title, detail)| { - *status == problem.status - && *code == problem.code - && *title == problem.title - && *detail == problem.detail - }) + let problem = ProblemDocument::parse_exact(body, MAXIMUM_PROBLEM_BYTES).ok()?; + if !REGISTERED_PROBLEMS + .iter() + .any(|(status, code, title, detail)| { + *status == problem.status + && *code == problem.code + && *title == problem.title + && *detail == problem.detail + }) || problem.type_uri != expected_type_uri(&problem.code) { return None; @@ -176,34 +163,16 @@ pub(crate) fn essence(value: &str) -> &str { /// Parse one exact lower-case W3C Trace Context version 0 header and return /// its canonical 32-character trace ID. -pub(crate) fn trace_id_from_traceparent(value: &str) -> Option { - let mut parts = value.split('-'); - let [version, trace_id, parent_id, flags] = - [parts.next()?, parts.next()?, parts.next()?, parts.next()?]; - if parts.next().is_some() - || version != "00" - || !is_canonical_trace_id(trace_id) - || !is_nonzero_lower_hex(parent_id, 16) - || !is_lower_hex(flags, 2) - { - return None; - } - Some(trace_id.to_owned()) -} - -pub(crate) fn is_canonical_trace_id(value: &str) -> bool { - is_nonzero_lower_hex(value, 32) -} - -fn is_nonzero_lower_hex(value: &str, length: usize) -> bool { - is_lower_hex(value, length) && value.bytes().any(|byte| byte != b'0') -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - value.len() == length - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +#[cfg(test)] +fn trace_id_from_traceparent(value: &str) -> Option { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "traceparent", + reqwest::header::HeaderValue::from_str(value).ok()?, + ); + registry_platform_httpsec::response_trace_id(&headers) + .ok() + .map(|trace_id| trace_id.as_str().to_owned()) } #[cfg(test)] diff --git a/crates/registry-evidence-client/src/token.rs b/crates/registry-evidence-client/src/token.rs index 8155f400e..c384a65c0 100644 --- a/crates/registry-evidence-client/src/token.rs +++ b/crates/registry-evidence-client/src/token.rs @@ -1,377 +1,5 @@ -//! Bearer credential acquisition for the Evidence request. -//! -//! A token never reaches a log line, an error, a `Debug` rendering, or a -//! snapshot. It is held in a wrapper that wipes its buffer on drop and is -//! exposed only where the outbound request header is built. +//! Compatibility re-exports for the product-neutral token provider API. -use std::fmt; - -use async_trait::async_trait; -use thiserror::Error; -use zeroize::Zeroizing; - -use crate::error::TransportKind; - -/// Longest accepted credential. Access tokens are bounded well below this; the -/// limit keeps a hostile provider from handing over an unbounded header. -const MAXIMUM_TOKEN_BYTES: usize = 8 * 1024; - -/// One bearer credential for one outbound request. -pub struct BearerToken(Zeroizing); - -impl BearerToken { - /// Accept a credential that can be placed in an `Authorization` header - /// without escaping or folding. - /// - /// The rejection carries no part of the value, so an invalid credential - /// cannot reach a diagnostic through the error path. - pub fn new(value: impl Into) -> Result { - let value = Zeroizing::new(value.into()); - if value.is_empty() || value.len() > MAXIMUM_TOKEN_BYTES { - return Err(TokenError::Invalid { - reason: "a bearer credential must be non-empty and within the accepted length", - }); - } - // Visible ASCII only. This is the header-safe subset, so no credential - // can inject a carriage return, a newline, or a byte the header - // encoder would have to escape. - if !value.bytes().all(|byte| byte.is_ascii_graphic()) { - return Err(TokenError::Invalid { - reason: "a bearer credential must contain only visible ASCII characters", - }); - } - Ok(Self(value)) - } - - /// The credential text, for building exactly one outbound header. - pub(crate) fn expose(&self) -> &str { - &self.0 - } -} - -impl Clone for BearerToken { - fn clone(&self) -> Self { - Self(Zeroizing::new(self.0.as_str().to_owned())) - } -} - -impl fmt::Debug for BearerToken { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("BearerToken") - .finish_non_exhaustive() - } -} - -/// Source of the bearer credential the client presents. -/// -/// Implementations may cache, refresh, or mint a credential. The client calls -/// this once per outbound request and never stores what it returns. -/// -/// This trait is `#[async_trait]`. An integrator implementing it outside this -/// crate needs the `async-trait` dependency themselves; it is not re-exported. -#[async_trait] -pub trait TokenProvider: Send + Sync { - async fn bearer_token(&self) -> Result; -} - -/// A credential the integrator already holds. -/// -/// This is the deployment where an operator, a supervisor, or an outer service -/// supplies the access token. Renewal is that caller's responsibility. -#[derive(Debug, Clone)] -pub struct StaticToken(BearerToken); - -impl StaticToken { - pub fn new(value: impl Into) -> Result { - Ok(Self(BearerToken::new(value)?)) - } -} - -#[async_trait] -impl TokenProvider for StaticToken { - async fn bearer_token(&self) -> Result { - Ok(self.0.clone()) - } -} - -/// Why a credential could not be supplied. -/// -/// Every message is fixed text. A provider must not place a credential, a -/// response body, or a header value in this error. -#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum TokenError { - #[error("the token provider could not supply a bearer credential")] - Unavailable, - #[error("the bearer credential is not usable: {reason}")] - Invalid { reason: &'static str }, - - /// The provider cannot be used as configured. The reason is fixed text - /// chosen by the provider, never caller data and never key material. - #[error("the token provider cannot be used as configured: {reason}")] - Configuration { reason: &'static str }, - - /// The exchange with the authorization server did not complete. - #[error("the token request did not complete: {kind}")] - Transport { kind: TransportKind }, - - /// The authorization server declined to issue a token. The registered error - /// code is the whole of what is reported. - #[error("the authorization server declined to issue a token: {code}")] - Refused { code: OAuthErrorCode }, - - /// The answer was not a token response this crate can use: an unexpected - /// status, an unexpected media type, an unreadable body, or a token type the - /// Evidence request cannot present. - #[error("the token response does not satisfy the OAuth 2.0 contract: status {status}")] - Protocol { status: u16 }, -} - -impl TokenError { - /// A stable, machine-readable name for which kind of token failure this is. - /// - /// It exists for callers that have to branch or aggregate without matching - /// an enum this crate may extend: a metric label, a structured log field, or - /// a language binding that carries the discriminant across a boundary. The - /// rendered message is for people and may be reworded; these names are part - /// of the crate's contract and will not be renamed. A variant added later - /// brings a new name rather than reusing one of these. - #[must_use] - pub fn kind(&self) -> &'static str { - match self { - Self::Unavailable => "unavailable", - Self::Invalid { .. } => "invalid_credential", - Self::Configuration { .. } => "configuration", - Self::Transport { .. } => "transport", - Self::Refused { .. } => "refused", - Self::Protocol { .. } => "protocol", - } - } -} - -/// The OAuth 2.0 error code an authorization server returned. -/// -/// This code is all a refused token request reports. The accompanying -/// `error_description` is server-authored text about a failed authentication -/// attempt, so it is dropped where the body is parsed rather than carried into a -/// diagnostic, and the client assertion and the key that signed it are never part -/// of any of these values. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum OAuthErrorCode { - InvalidRequest, - InvalidClient, - InvalidGrant, - UnauthorizedClient, - UnsupportedGrantType, - InvalidScope, - /// A code outside RFC 6749 section 5.2. The server's own spelling is - /// deliberately not kept: it is unbounded text from the failed exchange, and - /// the extension registry is open, so no closed variant could hold it. - Other, -} - -impl OAuthErrorCode { - /// The registered spelling, or a fixed name for a code from outside the - /// section 5.2 set. - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - Self::InvalidRequest => "invalid_request", - Self::InvalidClient => "invalid_client", - Self::InvalidGrant => "invalid_grant", - Self::UnauthorizedClient => "unauthorized_client", - Self::UnsupportedGrantType => "unsupported_grant_type", - Self::InvalidScope => "invalid_scope", - Self::Other => "unregistered_error_code", - } - } - - /// Read a code off the wire, keeping only whether it is one this crate names. - pub(crate) fn from_wire(code: &str) -> Self { - match code { - "invalid_request" => Self::InvalidRequest, - "invalid_client" => Self::InvalidClient, - "invalid_grant" => Self::InvalidGrant, - "unauthorized_client" => Self::UnauthorizedClient, - "unsupported_grant_type" => Self::UnsupportedGrantType, - "invalid_scope" => Self::InvalidScope, - _ => Self::Other, - } - } -} - -impl fmt::Display for OAuthErrorCode { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.as_str()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn a_static_provider_returns_the_configured_credential() { - let provider = StaticToken::new("header-safe-token").expect("the credential is accepted"); - let token = provider - .bearer_token() - .await - .expect("a static provider always succeeds"); - assert_eq!(token.expose(), "header-safe-token"); - } - - #[test] - fn unusable_credentials_are_refused_without_echoing_them() { - for candidate in [ - "", - "canary space", - "canary\ttab", - "canary\rcarriage-return", - "canary\nnewline", - "canary\u{00e9}-non-ascii", - ] { - let error = BearerToken::new(candidate).expect_err("the credential is refused"); - let rendered = error.to_string(); - assert!( - !rendered.contains("canary"), - "the error rendered part of the credential: {rendered}" - ); - } - assert!(BearerToken::new("A".repeat(MAXIMUM_TOKEN_BYTES + 1)).is_err()); - } - - /// A refusal names the registered code and nothing else, and every acquisition - /// failure renders as its own sentence so a support conversation can start - /// from the message alone. - #[test] - fn acquisition_failures_render_their_own_fixed_text() { - let cases = [ - ( - TokenError::Configuration { - reason: "the client identifier must not be empty", - }, - "the token provider cannot be used as configured: the client identifier must not be empty", - ), - ( - TokenError::Transport { - kind: TransportKind::Connect, - }, - "the token request did not complete: connection setup failed", - ), - ( - TokenError::Refused { - code: OAuthErrorCode::InvalidClient, - }, - "the authorization server declined to issue a token: invalid_client", - ), - ( - TokenError::Refused { - code: OAuthErrorCode::Other, - }, - "the authorization server declined to issue a token: unregistered_error_code", - ), - ( - TokenError::Protocol { status: 500 }, - "the token response does not satisfy the OAuth 2.0 contract: status 500", - ), - ]; - for (error, rendered) in &cases { - assert_eq!(&error.to_string(), rendered); - } - let renderings: std::collections::BTreeSet = - cases.iter().map(|(error, _)| error.to_string()).collect(); - assert_eq!( - renderings.len(), - cases.len(), - "two failures render the same text" - ); - } - - /// The discriminant is what a binding, a metric label, or a caller's own - /// branch reads, so every variant has one and no two share it. - #[test] - fn every_token_failure_reports_its_own_stable_kind() { - let cases = [ - (TokenError::Unavailable, "unavailable"), - ( - TokenError::Invalid { - reason: "a bearer credential must be non-empty and within the accepted length", - }, - "invalid_credential", - ), - ( - TokenError::Configuration { - reason: "the client identifier must not be empty", - }, - "configuration", - ), - ( - TokenError::Transport { - kind: TransportKind::Connect, - }, - "transport", - ), - ( - TokenError::Refused { - code: OAuthErrorCode::InvalidClient, - }, - "refused", - ), - (TokenError::Protocol { status: 500 }, "protocol"), - ]; - for (error, kind) in &cases { - assert_eq!(error.kind(), *kind, "{error}"); - } - let kinds: std::collections::BTreeSet<&str> = - cases.iter().map(|(error, _)| error.kind()).collect(); - assert_eq!(kinds.len(), cases.len(), "two variants share a kind"); - } - - /// A server may spell a code however it likes. Only the registered set is - /// named, so an unbounded spelling cannot travel in the error. - #[test] - fn unregistered_error_codes_collapse_to_one_name() { - assert_eq!( - OAuthErrorCode::from_wire("invalid_request"), - OAuthErrorCode::InvalidRequest - ); - assert_eq!( - OAuthErrorCode::from_wire("invalid_client"), - OAuthErrorCode::InvalidClient - ); - assert_eq!( - OAuthErrorCode::from_wire("invalid_grant"), - OAuthErrorCode::InvalidGrant - ); - assert_eq!( - OAuthErrorCode::from_wire("unauthorized_client"), - OAuthErrorCode::UnauthorizedClient - ); - assert_eq!( - OAuthErrorCode::from_wire("unsupported_grant_type"), - OAuthErrorCode::UnsupportedGrantType - ); - assert_eq!( - OAuthErrorCode::from_wire("invalid_scope"), - OAuthErrorCode::InvalidScope - ); - for candidate in ["", "Invalid_Client", "canary_extension_code"] { - let code = OAuthErrorCode::from_wire(candidate); - assert_eq!(code, OAuthErrorCode::Other, "{candidate}"); - assert!(!code.to_string().contains("canary"), "{candidate}"); - } - } - - #[test] - fn debug_output_never_carries_the_credential() { - let token = BearerToken::new("secret-canary-value").expect("the credential is accepted"); - let rendered = format!("{token:?}"); - assert!(!rendered.contains("secret-canary-value"), "{rendered}"); - - let provider = StaticToken::new("secret-canary-value").expect("the credential is accepted"); - let rendered = format!("{provider:?}"); - assert!(!rendered.contains("secret-canary-value"), "{rendered}"); - } -} +pub use registry_platform_httputil::{ + BearerToken, OAuthErrorCode, StaticToken, TokenError, TokenProvider, +}; diff --git a/crates/registry-evidence/Cargo.toml b/crates/registry-evidence/Cargo.toml index 6354160e5..671ded0f2 100644 --- a/crates/registry-evidence/Cargo.toml +++ b/crates/registry-evidence/Cargo.toml @@ -35,7 +35,7 @@ registry-platform-authcommon.workspace = true registry-platform-buildinfo.workspace = true registry-platform-crypto = { workspace = true, features = ["transit"] } registry-platform-config.workspace = true -registry-platform-httpsec.workspace = true +registry-platform-httpsec = { workspace = true, features = ["server"] } registry-platform-httputil.workspace = true registry-platform-oidc.workspace = true registry-platform-sdjwt.workspace = true diff --git a/crates/registry-platform-httpsec/Cargo.toml b/crates/registry-platform-httpsec/Cargo.toml index 6d11fb92f..3f6292ace 100644 --- a/crates/registry-platform-httpsec/Cargo.toml +++ b/crates/registry-platform-httpsec/Cargo.toml @@ -10,16 +10,20 @@ publish = false [lints] workspace = true +[features] +default = ["server"] +server = ["dep:axum", "dep:tower", "dep:tower-http", "dep:ulid", "dep:url"] + [dependencies] -axum.workspace = true +axum = { workspace = true, optional = true } http.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true -tower.workspace = true -tower-http.workspace = true -ulid.workspace = true -url.workspace = true +tower = { workspace = true, optional = true } +tower-http = { workspace = true, optional = true } +ulid = { workspace = true, optional = true } +url = { workspace = true, optional = true } [dev-dependencies] tokio.workspace = true diff --git a/crates/registry-platform-httpsec/README.md b/crates/registry-platform-httpsec/README.md index a395c24c0..644cf4f41 100644 --- a/crates/registry-platform-httpsec/README.md +++ b/crates/registry-platform-httpsec/README.md @@ -1,6 +1,6 @@ # registry-platform-httpsec -Axum and Tower helpers for browser-facing HTTP security. +Client-safe HTTP validation plus Axum and Tower helpers for HTTP security. ## What It Provides @@ -16,6 +16,9 @@ Axum and Tower helpers for browser-facing HTTP security. primitives for Registry service responses. - `ProblemBody`, the fixed value-free Registry Stack problem envelope with `type`, `title`, `status`, `detail`, `code`, and `traceId` members. +- `response_trace_id` for exact-one canonical W3C v0 response correlation. +- `ProblemDocument` for bounded exact-six-member received problems. Products + retain their own closed `ProblemDefinition` catalogs. ## Typical Use @@ -67,6 +70,8 @@ let _ = app; cargo test -p registry-platform-httpsec ``` +Client-only users can disable Axum and Tower with `default-features = false`. + ## License Apache-2.0. diff --git a/crates/registry-platform-httpsec/src/client.rs b/crates/registry-platform-httpsec/src/client.rs new file mode 100644 index 000000000..a8715debc --- /dev/null +++ b/crates/registry-platform-httpsec/src/client.rs @@ -0,0 +1,219 @@ +//! Strict, client-safe parsing for response correlation and Problem Details. + +use http::HeaderMap; +use serde::{Deserialize, Deserializer, Serialize}; +use thiserror::Error; + +/// Extract the trace identifier from exactly one canonical W3C Trace Context v0 field. +pub fn response_trace_id(headers: &HeaderMap) -> Result { + let mut values = headers.get_all("traceparent").iter(); + let value = match (values.next(), values.next()) { + (None, None) => return Err(ResponseTraceError::Missing), + (Some(_), Some(_)) => return Err(ResponseTraceError::Duplicate), + (Some(value), None) => value.to_str().map_err(|_| ResponseTraceError::Invalid)?, + (None, Some(_)) => unreachable!("a second header value cannot exist without a first"), + }; + parse_v0_traceparent(value) + .and_then(|trace_id| TraceId::parse(trace_id).ok()) + .ok_or(ResponseTraceError::Invalid) +} + +/// Value-free reason response trace correlation could not be trusted. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResponseTraceError { + #[error("the response carries no traceparent field")] + Missing, + #[error("the response carries more than one traceparent field")] + Duplicate, + #[error("the response traceparent is not canonical W3C Trace Context version 0")] + Invalid, +} + +/// A validated canonical W3C Trace Context trace identifier. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)] +#[serde(transparent)] +pub struct TraceId(String); + +impl TraceId { + pub fn parse(value: &str) -> Result { + if !is_nonzero_lower_hex(value, 32) { + return Err(TraceIdError); + } + Ok(Self(value.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for TraceId { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for TraceId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Error)] +#[error("trace identifier is invalid")] +pub struct TraceIdError; + +/// An owned exact-six-member Registry Stack Problem document received by a client. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ProblemDocument { + #[serde(rename = "type")] + pub type_uri: String, + pub title: String, + pub status: u16, + pub detail: String, + pub code: String, + pub trace_id: TraceId, +} + +impl ProblemDocument { + /// Parse a bounded exact-six-member document with a canonical trace identifier. + pub fn parse_exact(body: &[u8], max_bytes: usize) -> Result { + if body.is_empty() || body.len() > max_bytes { + return Err(ProblemDocumentError); + } + let problem: Self = serde_json::from_slice(body).map_err(|_| ProblemDocumentError)?; + Ok(problem) + } + + /// Match every product-owned public member against one closed definition. + #[must_use] + pub fn matches(&self, definition: &ProblemDefinition<'_>) -> bool { + self.type_uri == definition.type_uri + && self.title == definition.title + && self.status == definition.status + && self.detail == definition.detail + && self.code == definition.code + } + + /// Return the one product-owned definition matching every public member. + #[must_use] + pub fn definition_index(&self, definitions: &[ProblemDefinition<'_>]) -> Option { + let mut matches = definitions + .iter() + .enumerate() + .filter(|(_, definition)| self.matches(definition)); + let (index, _) = matches.next()?; + matches.next().is_none().then_some(index) + } +} + +/// A product-owned closed Problem definition. The shared crate owns no catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProblemDefinition<'a> { + pub type_uri: &'a str, + pub title: &'a str, + pub status: u16, + pub detail: &'a str, + pub code: &'a str, +} + +/// Value-free strict Problem parsing failure. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("the response Problem document is not an exact bounded Registry Stack problem")] +pub struct ProblemDocumentError; + +fn parse_v0_traceparent(value: &str) -> Option<&str> { + let mut parts = value.split('-'); + let [version, trace_id, parent_id, flags] = + [parts.next()?, parts.next()?, parts.next()?, parts.next()?]; + if parts.next().is_some() + || version != "00" + || !is_nonzero_lower_hex(trace_id, 32) + || !is_nonzero_lower_hex(parent_id, 16) + || !is_lower_hex(flags, 2) + { + return None; + } + Some(trace_id) +} + +fn is_nonzero_lower_hex(value: &str, length: usize) -> bool { + is_lower_hex(value, length) && value.bytes().any(|byte| byte != b'0') +} + +pub(crate) fn is_lower_hex(value: &str, length: usize) -> bool { + value.len() == length + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::HeaderValue; + use serde_json::json; + + const TRACE_ID: &str = "4bf92f3577b34da6a3ce929d0e0e4736"; + const TRACEPARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + + #[test] + fn response_trace_requires_exactly_one_canonical_v0_field() { + let mut headers = HeaderMap::new(); + assert_eq!( + response_trace_id(&headers), + Err(ResponseTraceError::Missing) + ); + headers.append("traceparent", HeaderValue::from_static(TRACEPARENT)); + assert_eq!(response_trace_id(&headers).unwrap().as_str(), TRACE_ID); + headers.append("traceparent", HeaderValue::from_static(TRACEPARENT)); + assert_eq!( + response_trace_id(&headers), + Err(ResponseTraceError::Duplicate) + ); + let mut malformed = HeaderMap::new(); + malformed.insert( + "traceparent", + HeaderValue::from_static("00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01"), + ); + assert_eq!( + response_trace_id(&malformed), + Err(ResponseTraceError::Invalid) + ); + } + + #[test] + fn problem_parsing_is_exact_bounded_and_product_owned() { + const DEFINITION: ProblemDefinition<'static> = ProblemDefinition { + type_uri: "https://id.example/problems/resource/not-found", + title: "Resource not found", + status: 404, + detail: "the requested resource was not found", + code: "resource.not_found", + }; + let body = serde_json::to_vec(&json!({ + "type": DEFINITION.type_uri, "title": DEFINITION.title, + "status": DEFINITION.status, "detail": DEFINITION.detail, + "code": DEFINITION.code, "traceId": TRACE_ID, + })) + .unwrap(); + let parsed = ProblemDocument::parse_exact(&body, body.len()).unwrap(); + assert_eq!(parsed.definition_index(&[DEFINITION]), Some(0)); + assert_eq!(parsed.definition_index(&[DEFINITION, DEFINITION]), None); + assert!(ProblemDocument::parse_exact(&body, body.len() - 1).is_err()); + let with_extra = serde_json::to_vec(&json!({ + "type": DEFINITION.type_uri, "title": DEFINITION.title, + "status": DEFINITION.status, "detail": DEFINITION.detail, + "code": DEFINITION.code, "traceId": TRACE_ID, "canary": true, + })) + .unwrap(); + assert!(ProblemDocument::parse_exact(&with_extra, 4096).is_err()); + } +} diff --git a/crates/registry-platform-httpsec/src/lib.rs b/crates/registry-platform-httpsec/src/lib.rs index 307102006..85f8754ce 100644 --- a/crates/registry-platform-httpsec/src/lib.rs +++ b/crates/registry-platform-httpsec/src/lib.rs @@ -1,985 +1,13 @@ -//! HTTP security helpers for Axum/Tower registry services. -//! -//! The crate keeps browser-facing defaults small and explicit: CORS validation, -//! common security headers, request-body limits, and RFC 9457-style -//! Problem Details responses. +//! HTTP security primitives shared by Registry Stack servers and clients. -use std::collections::BTreeMap; -use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; +mod client; -use axum::body::Body; -use axum::http::header::{HeaderName, HeaderValue, CONTENT_TYPE}; -use axum::http::{HeaderMap, Method, Request, Response, StatusCode}; -use axum::response::IntoResponse; -use serde::Serialize; -use serde_json::Value; -use tower::{Layer, Service}; -use tower_http::cors::{Any, CorsLayer}; -use tower_http::limit::RequestBodyLimitLayer; -use ulid::Ulid; +pub use client::{ + response_trace_id, ProblemDefinition, ProblemDocument, ProblemDocumentError, + ResponseTraceError, TraceId, TraceIdError, +}; -pub const DEFAULT_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024; - -/// A validated W3C Trace Context trace identifier. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(transparent)] -pub struct TraceId(String); - -impl TraceId { - /// Parse the 32 lower-case hexadecimal trace-id representation. - pub fn parse(value: &str) -> Result { - if !lower_hex(value, 32) || value.bytes().all(|byte| byte == b'0') { - return Err(TraceIdError); - } - Ok(Self(value.to_owned())) - } - - #[must_use] - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// Effective request trace. Invalid inbound `traceparent` values are replaced -/// with a server-created trace. One valid inbound `traceparent` is retained. -/// Caller-supplied `tracestate` never enters a response. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TraceContext { - pub trace_id: TraceId, - span_id: String, - trace_flags: String, -} - -impl TraceContext { - #[must_use] - pub fn from_headers(headers: &HeaderMap) -> Self { - let mut values = headers.get_all("traceparent").iter(); - let parsed = values - .next() - .and_then(|value| value.to_str().ok()) - .and_then(parse_traceparent); - if values.next().is_some() { - Self::server_created() - } else { - parsed.unwrap_or_else(Self::server_created) - } - } - - #[must_use] - pub fn server_created() -> Self { - let value = u128::from(Ulid::new()); - Self { - trace_id: TraceId(format!("{value:032x}")), - span_id: fresh_span_id(), - trace_flags: "01".into(), - } - } - - /// Attach the safe response trace context, dropping any caller-controlled - /// vendor state that a prior middleware might otherwise reflect. - pub fn apply(&self, headers: &mut HeaderMap) { - headers.remove("tracestate"); - let traceparent = format!( - "00-{}-{}-{}", - self.trace_id.as_str(), - self.span_id, - self.trace_flags - ); - if let Ok(value) = HeaderValue::from_str(&traceparent) { - headers.insert(HeaderName::from_static("traceparent"), value); - } - } -} - -fn parse_traceparent(value: &str) -> Option { - if !value.is_ascii() || value.len() != 55 { - return None; - } - let parts = value.split('-').collect::>(); - if parts.len() != 4 || parts[0] != "00" { - return None; - } - let trace = parts[1]; - let parent = parts[2]; - let flags = parts[3]; - if !lower_hex(trace, 32) - || trace.bytes().all(|byte| byte == b'0') - || !lower_hex(parent, 16) - || parent.bytes().all(|byte| byte == b'0') - || !lower_hex(flags, 2) - { - return None; - } - Some(TraceContext { - trace_id: TraceId(trace.to_owned()), - span_id: parent.to_owned(), - trace_flags: flags.to_owned(), - }) -} - -fn fresh_span_id() -> String { - let value = u128::from(Ulid::new()); - let span = u64::try_from(value & u128::from(u64::MAX)).unwrap_or(1); - format!("{:016x}", span.max(1)) -} - -fn lower_hex(value: &str, length: usize) -> bool { - value.len() == length - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] -#[error("trace identifier is invalid")] -pub struct TraceIdError; - -/// The fixed, value-free Registry Stack problem envelope. -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProblemBody { - #[serde(rename = "type")] - pub type_uri: String, - pub title: &'static str, - pub status: u16, - pub detail: &'static str, - pub code: &'static str, - pub trace_id: TraceId, -} - -#[derive(Debug, Clone, Default)] -pub struct CorsPolicy { - pub allowed_origins: Vec, - pub allowed_methods: Vec, - pub allowed_headers: Vec, - pub allow_credentials: bool, -} - -impl CorsPolicy { - pub fn validate(&self) -> Result<(), CorsValidationError> { - for origin in &self.allowed_origins { - if origin == "*" { - return Err(CorsValidationError::WildcardOrigin); - } - let _value = HeaderValue::from_str(origin) - .map_err(|_| CorsValidationError::MalformedOrigin(origin.clone()))?; - let parsed = url::Url::parse(origin) - .map_err(|_| CorsValidationError::MalformedOrigin(origin.clone()))?; - match parsed.scheme() { - "https" => {} - "http" if is_loopback_origin(&parsed) => {} - _ => { - return Err(CorsValidationError::MalformedOrigin(origin.clone())); - } - } - if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { - return Err(CorsValidationError::MalformedOrigin(origin.clone())); - } - } - if self.allow_credentials && self.allowed_headers.is_empty() { - return Err(CorsValidationError::CredentialedWildcardHeaders); - } - Ok(()) - } - - /// Build a [`CorsLayer`] from this policy, panicking if the policy is invalid. - /// - /// # Panics - /// - /// Panics if the policy fails validation (e.g. wildcard origin, credentialed - /// request without explicit allowed headers). Use [`try_layer`](Self::try_layer) - /// to handle the error gracefully instead. - #[deprecated( - note = "panics on invalid policy; use try_layer() and handle the CorsValidationError" - )] - pub fn layer(&self) -> CorsLayer { - self.validate() - .expect("invalid CORS policy must not be converted into a layer"); - self.layer_unchecked() - } - - pub fn try_layer(&self) -> Result { - self.validate()?; - Ok(self.layer_unchecked()) - } - - fn layer_unchecked(&self) -> CorsLayer { - if self.allowed_origins.is_empty() { - return CorsLayer::new(); - } - let origins: Vec = self - .allowed_origins - .iter() - .filter_map(|origin| HeaderValue::from_str(origin).ok()) - .collect(); - let methods = if self.allowed_methods.is_empty() { - vec![Method::GET, Method::POST, Method::OPTIONS] - } else { - self.allowed_methods.clone() - }; - let layer = CorsLayer::new() - .allow_origin(origins) - .allow_methods(methods) - .allow_credentials(self.allow_credentials); - if self.allowed_headers.is_empty() { - layer.allow_headers(Any) - } else { - layer.allow_headers(self.allowed_headers.clone()) - } - } -} - -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum CorsValidationError { - #[error("wildcard CORS origin is not allowed")] - WildcardOrigin, - #[error("credentialed CORS requires explicit allowed headers")] - CredentialedWildcardHeaders, - #[error("malformed CORS origin: {0}")] - MalformedOrigin(String), -} - -#[derive(Debug, Clone)] -pub struct CspBuilder { - default_src: Vec, - script_src: Vec, - style_src: Vec, - img_src: Vec, - connect_src: Vec, -} - -impl CspBuilder { - #[must_use] - pub fn restrictive() -> Self { - Self { - default_src: vec!["'self'".to_string()], - script_src: vec!["'self'".to_string()], - style_src: vec!["'self'".to_string()], - img_src: vec!["'self'".to_string(), "data:".to_string()], - connect_src: vec!["'self'".to_string()], - } - } - - pub fn header_value(&self) -> HeaderValue { - HeaderValue::from_str(&format!( - "default-src {}; script-src {}; style-src {}; img-src {}; connect-src {}; object-src 'none'; frame-ancestors 'none'", - self.default_src.join(" "), - self.script_src.join(" "), - self.style_src.join(" "), - self.img_src.join(" "), - self.connect_src.join(" "), - )) - .expect("CSP built from static directive names is a valid header") - } -} - -/// Default HSTS value applied by [`security_headers`]: two-year max-age with -/// `includeSubDomains`. Use [`SecurityHeadersLayer::without_hsts`] to disable -/// HSTS for deployments that terminate TLS upstream or serve plain HTTP. -pub const DEFAULT_HSTS_VALUE: &str = "max-age=63072000; includeSubDomains"; - -pub fn security_headers(csp: CspBuilder) -> SecurityHeadersLayer { - SecurityHeadersLayer { - csp: csp.header_value(), - hsts: Some(HeaderValue::from_static(DEFAULT_HSTS_VALUE)), - } -} - -#[derive(Debug, Clone)] -pub struct SecurityHeadersLayer { - csp: HeaderValue, - /// When `Some`, a `Strict-Transport-Security` header is inserted (if not - /// already present) by the service. Set to `None` via - /// [`SecurityHeadersLayer::without_hsts`] for deployments that terminate - /// TLS upstream or serve plain HTTP. - hsts: Option, -} - -impl SecurityHeadersLayer { - /// Disable the default `Strict-Transport-Security` header for deployments - /// that terminate TLS upstream (e.g. a load balancer or reverse proxy) or - /// that intentionally serve plain HTTP (e.g. internal cluster traffic). - #[must_use] - pub fn without_hsts(mut self) -> Self { - self.hsts = None; - self - } - - /// Override the `Strict-Transport-Security` header value. - /// - /// The default is `"max-age=63072000; includeSubDomains"`. Use this to - /// add `preload` or reduce `max-age` for staged rollouts. - #[must_use] - pub fn with_hsts(mut self, value: HeaderValue) -> Self { - self.hsts = Some(value); - self - } -} - -impl Layer for SecurityHeadersLayer { - type Service = SecurityHeadersService; - - fn layer(&self, inner: S) -> Self::Service { - SecurityHeadersService { - inner, - csp: self.csp.clone(), - hsts: self.hsts.clone(), - } - } -} - -#[derive(Debug, Clone)] -pub struct SecurityHeadersService { - inner: S, - csp: HeaderValue, - hsts: Option, -} - -impl Service> for SecurityHeadersService -where - S: Service, Response = Response> + Send + 'static, - S::Future: Send + 'static, - S::Error: Send + 'static, - ResBody: Send + 'static, -{ - type Response = Response; - type Error = S::Error; - type Future = Pin> + Send>>; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, request: Request) -> Self::Future { - let future = self.inner.call(request); - let csp = self.csp.clone(); - let hsts = self.hsts.clone(); - Box::pin(async move { - let mut response = future.await?; - insert_if_missing( - &mut response, - HeaderName::from_static("content-security-policy"), - csp, - ); - insert_if_missing( - &mut response, - HeaderName::from_static("x-content-type-options"), - HeaderValue::from_static("nosniff"), - ); - insert_if_missing( - &mut response, - HeaderName::from_static("referrer-policy"), - HeaderValue::from_static("no-referrer"), - ); - insert_if_missing( - &mut response, - HeaderName::from_static("x-frame-options"), - HeaderValue::from_static("DENY"), - ); - insert_if_missing( - &mut response, - HeaderName::from_static("permissions-policy"), - HeaderValue::from_static( - "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()", - ), - ); - insert_if_missing( - &mut response, - HeaderName::from_static("cross-origin-opener-policy"), - HeaderValue::from_static("same-origin"), - ); - if let Some(hsts_value) = hsts { - insert_if_missing( - &mut response, - HeaderName::from_static("strict-transport-security"), - hsts_value, - ); - } - Ok(response) - }) - } -} - -fn insert_if_missing(response: &mut Response, name: HeaderName, value: HeaderValue) { - if !response.headers().contains_key(&name) { - response.headers_mut().insert(name, value); - } -} - -pub fn corp_conditional() -> CorpConditionalLayer { - CorpConditionalLayer -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct CorpConditionalLayer; - -impl Layer for CorpConditionalLayer { - type Service = CorpConditionalService; - - fn layer(&self, inner: S) -> Self::Service { - CorpConditionalService { inner } - } -} - -#[derive(Debug, Clone)] -pub struct CorpConditionalService { - inner: S, -} - -impl Service> for CorpConditionalService -where - S: Service, Response = Response> + Send + 'static, - S::Future: Send + 'static, - S::Error: Send + 'static, - ResBody: Send + 'static, -{ - type Response = Response; - type Error = S::Error; - type Future = Pin> + Send>>; - - fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - self.inner.poll_ready(cx) - } - - fn call(&mut self, request: Request) -> Self::Future { - let future = self.inner.call(request); - Box::pin(async move { - let mut response = future.await?; - apply_conditional_corp(&mut response); - Ok(response) - }) - } -} - -pub fn apply_conditional_corp(response: &mut Response) { - let value = if response - .headers() - .contains_key("access-control-allow-origin") - { - HeaderValue::from_static("cross-origin") - } else { - HeaderValue::from_static("same-origin") - }; - response.headers_mut().insert( - HeaderName::from_static("cross-origin-resource-policy"), - value, - ); -} - -pub fn request_body_limit(max_bytes: usize) -> RequestBodyLimitLayer { - RequestBodyLimitLayer::new(max_bytes) -} - -pub fn request_body_limit_default() -> RequestBodyLimitLayer { - request_body_limit(DEFAULT_REQUEST_BODY_LIMIT_BYTES) -} - -pub fn hsts_header(max_age: u64, include_subdomains: bool, preload: bool) -> HeaderValue { - let mut value = format!("max-age={max_age}"); - if include_subdomains { - value.push_str("; includeSubDomains"); - } - if preload { - value.push_str("; preload"); - } - HeaderValue::from_str(&value).expect("HSTS directives are valid header bytes") -} - -pub fn apply_hsts(response: &mut Response, value: HeaderValue) { - insert_if_missing( - response, - HeaderName::from_static("strict-transport-security"), - value, - ); -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CrossOriginIsolation { - Disabled, - RequireCorp, - Credentialless, -} - -pub fn apply_cross_origin_isolation(response: &mut Response, mode: CrossOriginIsolation) { - if mode == CrossOriginIsolation::Disabled { - return; - } - insert_if_missing( - response, - HeaderName::from_static("cross-origin-opener-policy"), - HeaderValue::from_static("same-origin"), - ); - let coep = match mode { - CrossOriginIsolation::RequireCorp => "require-corp", - CrossOriginIsolation::Credentialless => "credentialless", - CrossOriginIsolation::Disabled => return, - }; - insert_if_missing( - response, - HeaderName::from_static("cross-origin-embedder-policy"), - HeaderValue::from_static(coep), - ); -} - -#[derive(Debug, Clone, Serialize)] -pub struct Problem { - #[serde(rename = "type")] - pub type_uri: String, - pub title: String, - #[serde(serialize_with = "serialize_status_code")] - pub status: StatusCode, - #[serde(skip_serializing_if = "Option::is_none")] - pub detail: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub instance: Option, - #[serde(flatten)] - pub extra: BTreeMap, -} - -impl Problem { - #[must_use] - pub fn new(type_uri: &str, title: &str, status: StatusCode) -> Self { - Self { - type_uri: type_uri.to_string(), - title: title.to_string(), - status, - detail: None, - instance: None, - extra: BTreeMap::new(), - } - } - - #[must_use] - /// Set public response detail. - /// - /// Pass only client-safe text. Server causes, upstream messages, secrets, - /// and raw validation internals belong in service logs, not this field. - pub fn detail(mut self, detail: impl Into) -> Self { - self.detail = Some(detail.into()); - self - } - - #[must_use] - pub fn with_extra(mut self, key: impl Into, value: Value) -> Self { - self.extra.insert(key.into(), value); - self - } - - pub fn into_response(self) -> axum::response::Response { - let status = self.status; - let mut response = (status, axum::Json(self)).into_response(); - response.headers_mut().insert( - CONTENT_TYPE, - HeaderValue::from_static("application/problem+json"), - ); - response - } -} - -fn serialize_status_code(status: &StatusCode, serializer: S) -> Result -where - S: serde::Serializer, -{ - serializer.serialize_u16(status.as_u16()) -} - -pub mod problem { - pub use super::Problem; -} - -pub async fn body_limit_problem_response(_request: Request) -> Response { - Problem::new( - "https://id.registrystack.org/problems/registry-platform/request/body-too-large", - "Payload Too Large", - StatusCode::PAYLOAD_TOO_LARGE, - ) - .detail("request body exceeds the configured limit") - .into_response() -} - -fn is_loopback_origin(url: &url::Url) -> bool { - let Some(host) = url.host() else { - return false; - }; - match host { - url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"), - url::Host::Ipv4(ip) => ip.is_loopback(), - url::Host::Ipv6(ip) => ip.is_loopback(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn trace_identifier_has_one_canonical_wire_shape() { - assert!(TraceId::parse("0123456789abcdef0123456789abcdef").is_ok()); - assert!(TraceId::parse("00000000000000000000000000000000").is_err()); - assert!(TraceId::parse("not-a-trace").is_err()); - } - - #[test] - fn version_zero_traceparent_rejects_non_lowercase_hex() { - for value in [ - "00-0123456789abcdeF0123456789abcdef-0123456789abcdef-01", - "00-0123456789abcdef0123456789abcdef-0123456789abcdeF-01", - "00-0123456789abcdef0123456789abcdef-0123456789abcdef-0A", - ] { - assert!( - parse_traceparent(value).is_none(), - "accepted non-lowercase traceparent {value}" - ); - } - } - - #[test] - fn valid_traceparent_is_preserved_without_vendor_state() { - let inbound = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-00"; - let mut request_headers = HeaderMap::new(); - request_headers.insert("traceparent", HeaderValue::from_static(inbound)); - - let trace = TraceContext::from_headers(&request_headers); - assert_eq!(trace.trace_id.as_str(), "0123456789abcdef0123456789abcdef"); - - let mut response_headers = HeaderMap::new(); - trace.apply(&mut response_headers); - let traceparent = response_headers - .get("traceparent") - .expect("server trace context is applied") - .to_str() - .expect("traceparent is ASCII"); - assert_eq!(traceparent, inbound); - } - - #[test] - fn invalid_traceparent_is_replaced_and_tracestate_is_never_echoed() { - const CANARY: &str = "7tenant@vendor-system=caller-controlled-canary"; - let supplied_trace_id = "0123456789abcdef0123456789abcdeF"; - let mut request_headers = HeaderMap::new(); - request_headers.insert( - "traceparent", - HeaderValue::from_str(&format!("00-{supplied_trace_id}-0123456789abcdef-00")) - .expect("test traceparent is an HTTP header value"), - ); - request_headers.insert("tracestate", HeaderValue::from_static(CANARY)); - - let trace = TraceContext::from_headers(&request_headers); - assert_ne!( - trace.trace_id.as_str(), - supplied_trace_id.to_ascii_lowercase() - ); - let mut response_headers = HeaderMap::new(); - response_headers.insert("tracestate", HeaderValue::from_static(CANARY)); - trace.apply(&mut response_headers); - assert!(!response_headers.contains_key("tracestate")); - assert!(response_headers.values().all(|value| { - !value - .to_str() - .expect("response headers are ASCII") - .contains("caller-controlled-canary") - })); - } - - #[test] - fn duplicate_traceparent_is_replaced_with_server_context() { - let supplied_trace_id = "0123456789abcdef0123456789abcdef"; - let mut request_headers = HeaderMap::new(); - request_headers.append( - "traceparent", - HeaderValue::from_static("00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"), - ); - request_headers.append( - "traceparent", - HeaderValue::from_static("00-0123456789abcdef0123456789abcdef-fedcba9876543210-01"), - ); - - let trace = TraceContext::from_headers(&request_headers); - assert_ne!(trace.trace_id.as_str(), supplied_trace_id); - } - - #[test] - fn cors_validation_rejects_localhost_prefix_attack() { - let policy = CorsPolicy { - allowed_origins: vec!["http://localhost.evil.test".to_string()], - allowed_methods: Vec::new(), - allowed_headers: Vec::new(), - allow_credentials: true, - }; - assert!(matches!( - policy.validate(), - Err(CorsValidationError::MalformedOrigin(_)) - )); - } - - #[test] - fn cors_validation_accepts_loopback_dev_origin() { - let policy = CorsPolicy { - allowed_origins: vec![ - "http://localhost:3000".to_string(), - "http://127.0.0.1:3000".to_string(), - ], - allowed_methods: Vec::new(), - allowed_headers: Vec::new(), - allow_credentials: false, - }; - assert!(policy.validate().is_ok()); - } - - #[test] - fn problem_response_uses_problem_json_content_type() { - let response = - Problem::new("about:blank", "Bad Request", StatusCode::BAD_REQUEST).into_response(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!( - response.headers().get(CONTENT_TYPE).unwrap(), - "application/problem+json" - ); - } - - #[test] - fn problem_response_serialises_type_title_status_and_detail() { - let value = serde_json::to_value( - Problem::new( - "https://example.com/problems/test", - "Test Error", - StatusCode::UNPROCESSABLE_ENTITY, - ) - .detail("something was wrong"), - ) - .expect("problem serializes"); - - assert_eq!(value["type"], "https://example.com/problems/test"); - assert_eq!(value["title"], "Test Error"); - assert_eq!(value["status"], 422); - assert_eq!(value["detail"], "something was wrong"); - } - - #[test] - fn cors_rejects_wildcard() { - let policy = CorsPolicy { - allowed_origins: vec!["*".to_string()], - allowed_methods: Vec::new(), - allowed_headers: Vec::new(), - allow_credentials: false, - }; - assert!(matches!( - policy.validate(), - Err(CorsValidationError::WildcardOrigin) - )); - } - - #[test] - fn credentialed_cors_rejects_wildcard_headers() { - let policy = CorsPolicy { - allowed_origins: vec!["https://app.example.test".to_string()], - allowed_methods: Vec::new(), - allowed_headers: Vec::new(), - allow_credentials: true, - }; - assert!(matches!( - policy.validate(), - Err(CorsValidationError::CredentialedWildcardHeaders) - )); - } - - #[test] - #[should_panic(expected = "invalid CORS policy")] - #[allow(deprecated)] - fn credentialed_cors_layer_rejects_wildcard_headers() { - let _layer = CorsPolicy { - allowed_origins: vec!["https://app.example.test".to_string()], - allowed_methods: Vec::new(), - allowed_headers: Vec::new(), - allow_credentials: true, - } - .layer(); - } - - #[test] - fn cors_try_layer_returns_validation_error_instead_of_panicking() { - let err = CorsPolicy { - allowed_origins: vec!["*".to_string()], - allowed_methods: Vec::new(), - allowed_headers: Vec::new(), - allow_credentials: false, - } - .try_layer() - .expect_err("wildcard origin rejects"); - assert!(matches!(err, CorsValidationError::WildcardOrigin)); - } - - #[test] - fn hsts_and_cross_origin_isolation_helpers_set_headers_without_overwriting() { - let mut response = Response::new(()); - apply_hsts(&mut response, hsts_header(31_536_000, true, true)); - apply_cross_origin_isolation(&mut response, CrossOriginIsolation::RequireCorp); - - assert_eq!( - response.headers().get("strict-transport-security").unwrap(), - "max-age=31536000; includeSubDomains; preload" - ); - assert_eq!( - response - .headers() - .get("cross-origin-opener-policy") - .unwrap(), - "same-origin" - ); - assert_eq!( - response - .headers() - .get("cross-origin-embedder-policy") - .unwrap(), - "require-corp" - ); - - response.headers_mut().insert( - "cross-origin-embedder-policy", - HeaderValue::from_static("credentialless"), - ); - apply_cross_origin_isolation(&mut response, CrossOriginIsolation::RequireCorp); - assert_eq!( - response - .headers() - .get("cross-origin-embedder-policy") - .unwrap(), - "credentialless" - ); - } - - #[test] - fn request_body_limit_default_is_one_mebibyte() { - assert_eq!(DEFAULT_REQUEST_BODY_LIMIT_BYTES, 1024 * 1024); - let _layer = request_body_limit_default(); - } - - #[tokio::test] - async fn security_headers_install_shared_baseline_without_overwriting_csp() { - use tower::service_fn; - use tower::ServiceExt; - - let service = security_headers(CspBuilder::restrictive()).layer(service_fn( - |_request: Request| async { - let mut response = Response::new(Body::empty()); - response.headers_mut().insert( - HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'none'"), - ); - Ok::<_, std::convert::Infallible>(response) - }, - )); - - let response = service - .oneshot(Request::new(Body::empty())) - .await - .expect("security header service responds"); - let headers = response.headers(); - assert_eq!( - headers.get("content-security-policy"), - Some(&HeaderValue::from_static("default-src 'none'")) - ); - assert_eq!( - headers.get("x-content-type-options"), - Some(&HeaderValue::from_static("nosniff")) - ); - assert_eq!( - headers.get("referrer-policy"), - Some(&HeaderValue::from_static("no-referrer")) - ); - assert_eq!( - headers.get("x-frame-options"), - Some(&HeaderValue::from_static("DENY")) - ); - assert_eq!( - headers.get("permissions-policy"), - Some(&HeaderValue::from_static( - "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()" - )) - ); - assert_eq!( - headers.get("cross-origin-opener-policy"), - Some(&HeaderValue::from_static("same-origin")) - ); - // HTTPSEC-01: HSTS is included in the default baseline. - assert_eq!( - headers.get("strict-transport-security"), - Some(&HeaderValue::from_static(DEFAULT_HSTS_VALUE)) - ); - } - - // HTTPSEC-01: HSTS is present by default via security_headers(). - #[tokio::test] - async fn security_headers_emits_hsts_by_default() { - use tower::service_fn; - use tower::ServiceExt; - - let service = security_headers(CspBuilder::restrictive()).layer(service_fn( - |_request: Request| async { - Ok::<_, std::convert::Infallible>(Response::new(Body::empty())) - }, - )); - let response = service - .oneshot(Request::new(Body::empty())) - .await - .expect("service responds"); - assert_eq!( - response.headers().get("strict-transport-security"), - Some(&HeaderValue::from_static(DEFAULT_HSTS_VALUE)), - "HSTS must be present by default" - ); - } - - // HTTPSEC-01: without_hsts() opts out of the HSTS header. - #[tokio::test] - async fn security_headers_without_hsts_omits_hsts_header() { - use tower::service_fn; - use tower::ServiceExt; - - let service = security_headers(CspBuilder::restrictive()) - .without_hsts() - .layer(service_fn(|_request: Request| async { - Ok::<_, std::convert::Infallible>(Response::new(Body::empty())) - })); - let response = service - .oneshot(Request::new(Body::empty())) - .await - .expect("service responds"); - assert!( - response - .headers() - .get("strict-transport-security") - .is_none(), - "HSTS must be absent after without_hsts()" - ); - } - - #[test] - fn conditional_corp_matches_cors_response() { - let mut response = Response::new(()); - apply_conditional_corp(&mut response); - assert_eq!( - response - .headers() - .get("cross-origin-resource-policy") - .unwrap(), - "same-origin" - ); - - response.headers_mut().insert( - "access-control-allow-origin", - HeaderValue::from_static("https://example.test"), - ); - apply_conditional_corp(&mut response); - assert_eq!( - response - .headers() - .get("cross-origin-resource-policy") - .unwrap(), - "cross-origin" - ); - } -} +#[cfg(feature = "server")] +mod server; +#[cfg(feature = "server")] +pub use server::*; diff --git a/crates/registry-platform-httpsec/src/server.rs b/crates/registry-platform-httpsec/src/server.rs new file mode 100644 index 000000000..4f9b70ab1 --- /dev/null +++ b/crates/registry-platform-httpsec/src/server.rs @@ -0,0 +1,957 @@ +//! HTTP security helpers for Axum/Tower registry services. +//! +//! The crate keeps browser-facing defaults small and explicit: CORS validation, +//! common security headers, request-body limits, and RFC 9457-style +//! Problem Details responses. + +use std::collections::BTreeMap; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use axum::body::Body; +use axum::http::header::{HeaderName, HeaderValue, CONTENT_TYPE}; +use axum::http::{HeaderMap, Method, Request, Response, StatusCode}; +use axum::response::IntoResponse; +use serde::Serialize; +use serde_json::Value; +use tower::{Layer, Service}; +use tower_http::cors::{Any, CorsLayer}; +use tower_http::limit::RequestBodyLimitLayer; +use ulid::Ulid; + +use crate::{client::is_lower_hex as lower_hex, TraceId}; + +pub const DEFAULT_REQUEST_BODY_LIMIT_BYTES: usize = 1024 * 1024; + +/// Effective request trace. Invalid inbound `traceparent` values are replaced +/// with a server-created trace. One valid inbound `traceparent` is retained. +/// Caller-supplied `tracestate` never enters a response. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceContext { + pub trace_id: TraceId, + span_id: String, + trace_flags: String, +} + +impl TraceContext { + #[must_use] + pub fn from_headers(headers: &HeaderMap) -> Self { + let mut values = headers.get_all("traceparent").iter(); + let parsed = values + .next() + .and_then(|value| value.to_str().ok()) + .and_then(parse_traceparent); + if values.next().is_some() { + Self::server_created() + } else { + parsed.unwrap_or_else(Self::server_created) + } + } + + #[must_use] + pub fn server_created() -> Self { + let value = u128::from(Ulid::new()); + Self { + trace_id: TraceId::parse(&format!("{value:032x}")) + .expect("a nonzero ULID is a canonical trace identifier"), + span_id: fresh_span_id(), + trace_flags: "01".into(), + } + } + + /// Attach the safe response trace context, dropping any caller-controlled + /// vendor state that a prior middleware might otherwise reflect. + pub fn apply(&self, headers: &mut HeaderMap) { + headers.remove("tracestate"); + let traceparent = format!( + "00-{}-{}-{}", + self.trace_id.as_str(), + self.span_id, + self.trace_flags + ); + if let Ok(value) = HeaderValue::from_str(&traceparent) { + headers.insert(HeaderName::from_static("traceparent"), value); + } + } +} + +fn parse_traceparent(value: &str) -> Option { + if !value.is_ascii() || value.len() != 55 { + return None; + } + let parts = value.split('-').collect::>(); + if parts.len() != 4 || parts[0] != "00" { + return None; + } + let trace = parts[1]; + let parent = parts[2]; + let flags = parts[3]; + if !lower_hex(trace, 32) + || trace.bytes().all(|byte| byte == b'0') + || !lower_hex(parent, 16) + || parent.bytes().all(|byte| byte == b'0') + || !lower_hex(flags, 2) + { + return None; + } + Some(TraceContext { + trace_id: TraceId::parse(trace).ok()?, + span_id: parent.to_owned(), + trace_flags: flags.to_owned(), + }) +} + +fn fresh_span_id() -> String { + let value = u128::from(Ulid::new()); + let span = u64::try_from(value & u128::from(u64::MAX)).unwrap_or(1); + format!("{:016x}", span.max(1)) +} + +/// The fixed, value-free Registry Stack problem envelope. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProblemBody { + #[serde(rename = "type")] + pub type_uri: String, + pub title: &'static str, + pub status: u16, + pub detail: &'static str, + pub code: &'static str, + pub trace_id: TraceId, +} + +#[derive(Debug, Clone, Default)] +pub struct CorsPolicy { + pub allowed_origins: Vec, + pub allowed_methods: Vec, + pub allowed_headers: Vec, + pub allow_credentials: bool, +} + +impl CorsPolicy { + pub fn validate(&self) -> Result<(), CorsValidationError> { + for origin in &self.allowed_origins { + if origin == "*" { + return Err(CorsValidationError::WildcardOrigin); + } + let _value = HeaderValue::from_str(origin) + .map_err(|_| CorsValidationError::MalformedOrigin(origin.clone()))?; + let parsed = url::Url::parse(origin) + .map_err(|_| CorsValidationError::MalformedOrigin(origin.clone()))?; + match parsed.scheme() { + "https" => {} + "http" if is_loopback_origin(&parsed) => {} + _ => { + return Err(CorsValidationError::MalformedOrigin(origin.clone())); + } + } + if parsed.path() != "/" || parsed.query().is_some() || parsed.fragment().is_some() { + return Err(CorsValidationError::MalformedOrigin(origin.clone())); + } + } + if self.allow_credentials && self.allowed_headers.is_empty() { + return Err(CorsValidationError::CredentialedWildcardHeaders); + } + Ok(()) + } + + /// Build a [`CorsLayer`] from this policy, panicking if the policy is invalid. + /// + /// # Panics + /// + /// Panics if the policy fails validation (e.g. wildcard origin, credentialed + /// request without explicit allowed headers). Use [`try_layer`](Self::try_layer) + /// to handle the error gracefully instead. + #[deprecated( + note = "panics on invalid policy; use try_layer() and handle the CorsValidationError" + )] + pub fn layer(&self) -> CorsLayer { + self.validate() + .expect("invalid CORS policy must not be converted into a layer"); + self.layer_unchecked() + } + + pub fn try_layer(&self) -> Result { + self.validate()?; + Ok(self.layer_unchecked()) + } + + fn layer_unchecked(&self) -> CorsLayer { + if self.allowed_origins.is_empty() { + return CorsLayer::new(); + } + let origins: Vec = self + .allowed_origins + .iter() + .filter_map(|origin| HeaderValue::from_str(origin).ok()) + .collect(); + let methods = if self.allowed_methods.is_empty() { + vec![Method::GET, Method::POST, Method::OPTIONS] + } else { + self.allowed_methods.clone() + }; + let layer = CorsLayer::new() + .allow_origin(origins) + .allow_methods(methods) + .allow_credentials(self.allow_credentials); + if self.allowed_headers.is_empty() { + layer.allow_headers(Any) + } else { + layer.allow_headers(self.allowed_headers.clone()) + } + } +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CorsValidationError { + #[error("wildcard CORS origin is not allowed")] + WildcardOrigin, + #[error("credentialed CORS requires explicit allowed headers")] + CredentialedWildcardHeaders, + #[error("malformed CORS origin: {0}")] + MalformedOrigin(String), +} + +#[derive(Debug, Clone)] +pub struct CspBuilder { + default_src: Vec, + script_src: Vec, + style_src: Vec, + img_src: Vec, + connect_src: Vec, +} + +impl CspBuilder { + #[must_use] + pub fn restrictive() -> Self { + Self { + default_src: vec!["'self'".to_string()], + script_src: vec!["'self'".to_string()], + style_src: vec!["'self'".to_string()], + img_src: vec!["'self'".to_string(), "data:".to_string()], + connect_src: vec!["'self'".to_string()], + } + } + + pub fn header_value(&self) -> HeaderValue { + HeaderValue::from_str(&format!( + "default-src {}; script-src {}; style-src {}; img-src {}; connect-src {}; object-src 'none'; frame-ancestors 'none'", + self.default_src.join(" "), + self.script_src.join(" "), + self.style_src.join(" "), + self.img_src.join(" "), + self.connect_src.join(" "), + )) + .expect("CSP built from static directive names is a valid header") + } +} + +/// Default HSTS value applied by [`security_headers`]: two-year max-age with +/// `includeSubDomains`. Use [`SecurityHeadersLayer::without_hsts`] to disable +/// HSTS for deployments that terminate TLS upstream or serve plain HTTP. +pub const DEFAULT_HSTS_VALUE: &str = "max-age=63072000; includeSubDomains"; + +pub fn security_headers(csp: CspBuilder) -> SecurityHeadersLayer { + SecurityHeadersLayer { + csp: csp.header_value(), + hsts: Some(HeaderValue::from_static(DEFAULT_HSTS_VALUE)), + } +} + +#[derive(Debug, Clone)] +pub struct SecurityHeadersLayer { + csp: HeaderValue, + /// When `Some`, a `Strict-Transport-Security` header is inserted (if not + /// already present) by the service. Set to `None` via + /// [`SecurityHeadersLayer::without_hsts`] for deployments that terminate + /// TLS upstream or serve plain HTTP. + hsts: Option, +} + +impl SecurityHeadersLayer { + /// Disable the default `Strict-Transport-Security` header for deployments + /// that terminate TLS upstream (e.g. a load balancer or reverse proxy) or + /// that intentionally serve plain HTTP (e.g. internal cluster traffic). + #[must_use] + pub fn without_hsts(mut self) -> Self { + self.hsts = None; + self + } + + /// Override the `Strict-Transport-Security` header value. + /// + /// The default is `"max-age=63072000; includeSubDomains"`. Use this to + /// add `preload` or reduce `max-age` for staged rollouts. + #[must_use] + pub fn with_hsts(mut self, value: HeaderValue) -> Self { + self.hsts = Some(value); + self + } +} + +impl Layer for SecurityHeadersLayer { + type Service = SecurityHeadersService; + + fn layer(&self, inner: S) -> Self::Service { + SecurityHeadersService { + inner, + csp: self.csp.clone(), + hsts: self.hsts.clone(), + } + } +} + +#[derive(Debug, Clone)] +pub struct SecurityHeadersService { + inner: S, + csp: HeaderValue, + hsts: Option, +} + +impl Service> for SecurityHeadersService +where + S: Service, Response = Response> + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, + ResBody: Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, request: Request) -> Self::Future { + let future = self.inner.call(request); + let csp = self.csp.clone(); + let hsts = self.hsts.clone(); + Box::pin(async move { + let mut response = future.await?; + insert_if_missing( + &mut response, + HeaderName::from_static("content-security-policy"), + csp, + ); + insert_if_missing( + &mut response, + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + insert_if_missing( + &mut response, + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("no-referrer"), + ); + insert_if_missing( + &mut response, + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + ); + insert_if_missing( + &mut response, + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static( + "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()", + ), + ); + insert_if_missing( + &mut response, + HeaderName::from_static("cross-origin-opener-policy"), + HeaderValue::from_static("same-origin"), + ); + if let Some(hsts_value) = hsts { + insert_if_missing( + &mut response, + HeaderName::from_static("strict-transport-security"), + hsts_value, + ); + } + Ok(response) + }) + } +} + +fn insert_if_missing(response: &mut Response, name: HeaderName, value: HeaderValue) { + if !response.headers().contains_key(&name) { + response.headers_mut().insert(name, value); + } +} + +pub fn corp_conditional() -> CorpConditionalLayer { + CorpConditionalLayer +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct CorpConditionalLayer; + +impl Layer for CorpConditionalLayer { + type Service = CorpConditionalService; + + fn layer(&self, inner: S) -> Self::Service { + CorpConditionalService { inner } + } +} + +#[derive(Debug, Clone)] +pub struct CorpConditionalService { + inner: S, +} + +impl Service> for CorpConditionalService +where + S: Service, Response = Response> + Send + 'static, + S::Future: Send + 'static, + S::Error: Send + 'static, + ResBody: Send + 'static, +{ + type Response = Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, request: Request) -> Self::Future { + let future = self.inner.call(request); + Box::pin(async move { + let mut response = future.await?; + apply_conditional_corp(&mut response); + Ok(response) + }) + } +} + +pub fn apply_conditional_corp(response: &mut Response) { + let value = if response + .headers() + .contains_key("access-control-allow-origin") + { + HeaderValue::from_static("cross-origin") + } else { + HeaderValue::from_static("same-origin") + }; + response.headers_mut().insert( + HeaderName::from_static("cross-origin-resource-policy"), + value, + ); +} + +pub fn request_body_limit(max_bytes: usize) -> RequestBodyLimitLayer { + RequestBodyLimitLayer::new(max_bytes) +} + +pub fn request_body_limit_default() -> RequestBodyLimitLayer { + request_body_limit(DEFAULT_REQUEST_BODY_LIMIT_BYTES) +} + +pub fn hsts_header(max_age: u64, include_subdomains: bool, preload: bool) -> HeaderValue { + let mut value = format!("max-age={max_age}"); + if include_subdomains { + value.push_str("; includeSubDomains"); + } + if preload { + value.push_str("; preload"); + } + HeaderValue::from_str(&value).expect("HSTS directives are valid header bytes") +} + +pub fn apply_hsts(response: &mut Response, value: HeaderValue) { + insert_if_missing( + response, + HeaderName::from_static("strict-transport-security"), + value, + ); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CrossOriginIsolation { + Disabled, + RequireCorp, + Credentialless, +} + +pub fn apply_cross_origin_isolation(response: &mut Response, mode: CrossOriginIsolation) { + if mode == CrossOriginIsolation::Disabled { + return; + } + insert_if_missing( + response, + HeaderName::from_static("cross-origin-opener-policy"), + HeaderValue::from_static("same-origin"), + ); + let coep = match mode { + CrossOriginIsolation::RequireCorp => "require-corp", + CrossOriginIsolation::Credentialless => "credentialless", + CrossOriginIsolation::Disabled => return, + }; + insert_if_missing( + response, + HeaderName::from_static("cross-origin-embedder-policy"), + HeaderValue::from_static(coep), + ); +} + +#[derive(Debug, Clone, Serialize)] +pub struct Problem { + #[serde(rename = "type")] + pub type_uri: String, + pub title: String, + #[serde(serialize_with = "serialize_status_code")] + pub status: StatusCode, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance: Option, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl Problem { + #[must_use] + pub fn new(type_uri: &str, title: &str, status: StatusCode) -> Self { + Self { + type_uri: type_uri.to_string(), + title: title.to_string(), + status, + detail: None, + instance: None, + extra: BTreeMap::new(), + } + } + + #[must_use] + /// Set public response detail. + /// + /// Pass only client-safe text. Server causes, upstream messages, secrets, + /// and raw validation internals belong in service logs, not this field. + pub fn detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + #[must_use] + pub fn with_extra(mut self, key: impl Into, value: Value) -> Self { + self.extra.insert(key.into(), value); + self + } + + pub fn into_response(self) -> axum::response::Response { + let status = self.status; + let mut response = (status, axum::Json(self)).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/problem+json"), + ); + response + } +} + +fn serialize_status_code(status: &StatusCode, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_u16(status.as_u16()) +} + +pub mod problem { + pub use super::Problem; +} + +pub async fn body_limit_problem_response(_request: Request) -> Response { + Problem::new( + "https://id.registrystack.org/problems/registry-platform/request/body-too-large", + "Payload Too Large", + StatusCode::PAYLOAD_TOO_LARGE, + ) + .detail("request body exceeds the configured limit") + .into_response() +} + +fn is_loopback_origin(url: &url::Url) -> bool { + let Some(host) = url.host() else { + return false; + }; + match host { + url::Host::Domain(domain) => domain.eq_ignore_ascii_case("localhost"), + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trace_identifier_has_one_canonical_wire_shape() { + assert!(TraceId::parse("0123456789abcdef0123456789abcdef").is_ok()); + assert!(TraceId::parse("00000000000000000000000000000000").is_err()); + assert!(TraceId::parse("not-a-trace").is_err()); + } + + #[test] + fn version_zero_traceparent_rejects_non_lowercase_hex() { + for value in [ + "00-0123456789abcdeF0123456789abcdef-0123456789abcdef-01", + "00-0123456789abcdef0123456789abcdef-0123456789abcdeF-01", + "00-0123456789abcdef0123456789abcdef-0123456789abcdef-0A", + ] { + assert!( + parse_traceparent(value).is_none(), + "accepted non-lowercase traceparent {value}" + ); + } + } + + #[test] + fn valid_traceparent_is_preserved_without_vendor_state() { + let inbound = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-00"; + let mut request_headers = HeaderMap::new(); + request_headers.insert("traceparent", HeaderValue::from_static(inbound)); + + let trace = TraceContext::from_headers(&request_headers); + assert_eq!(trace.trace_id.as_str(), "0123456789abcdef0123456789abcdef"); + + let mut response_headers = HeaderMap::new(); + trace.apply(&mut response_headers); + let traceparent = response_headers + .get("traceparent") + .expect("server trace context is applied") + .to_str() + .expect("traceparent is ASCII"); + assert_eq!(traceparent, inbound); + } + + #[test] + fn invalid_traceparent_is_replaced_and_tracestate_is_never_echoed() { + const CANARY: &str = "7tenant@vendor-system=caller-controlled-canary"; + let supplied_trace_id = "0123456789abcdef0123456789abcdeF"; + let mut request_headers = HeaderMap::new(); + request_headers.insert( + "traceparent", + HeaderValue::from_str(&format!("00-{supplied_trace_id}-0123456789abcdef-00")) + .expect("test traceparent is an HTTP header value"), + ); + request_headers.insert("tracestate", HeaderValue::from_static(CANARY)); + + let trace = TraceContext::from_headers(&request_headers); + assert_ne!( + trace.trace_id.as_str(), + supplied_trace_id.to_ascii_lowercase() + ); + let mut response_headers = HeaderMap::new(); + response_headers.insert("tracestate", HeaderValue::from_static(CANARY)); + trace.apply(&mut response_headers); + assert!(!response_headers.contains_key("tracestate")); + assert!(response_headers.values().all(|value| { + !value + .to_str() + .expect("response headers are ASCII") + .contains("caller-controlled-canary") + })); + } + + #[test] + fn duplicate_traceparent_is_replaced_with_server_context() { + let supplied_trace_id = "0123456789abcdef0123456789abcdef"; + let mut request_headers = HeaderMap::new(); + request_headers.append( + "traceparent", + HeaderValue::from_static("00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"), + ); + request_headers.append( + "traceparent", + HeaderValue::from_static("00-0123456789abcdef0123456789abcdef-fedcba9876543210-01"), + ); + + let trace = TraceContext::from_headers(&request_headers); + assert_ne!(trace.trace_id.as_str(), supplied_trace_id); + } + + #[test] + fn cors_validation_rejects_localhost_prefix_attack() { + let policy = CorsPolicy { + allowed_origins: vec!["http://localhost.evil.test".to_string()], + allowed_methods: Vec::new(), + allowed_headers: Vec::new(), + allow_credentials: true, + }; + assert!(matches!( + policy.validate(), + Err(CorsValidationError::MalformedOrigin(_)) + )); + } + + #[test] + fn cors_validation_accepts_loopback_dev_origin() { + let policy = CorsPolicy { + allowed_origins: vec![ + "http://localhost:3000".to_string(), + "http://127.0.0.1:3000".to_string(), + ], + allowed_methods: Vec::new(), + allowed_headers: Vec::new(), + allow_credentials: false, + }; + assert!(policy.validate().is_ok()); + } + + #[test] + fn problem_response_uses_problem_json_content_type() { + let response = + Problem::new("about:blank", "Bad Request", StatusCode::BAD_REQUEST).into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.headers().get(CONTENT_TYPE).unwrap(), + "application/problem+json" + ); + } + + #[test] + fn problem_response_serialises_type_title_status_and_detail() { + let value = serde_json::to_value( + Problem::new( + "https://example.com/problems/test", + "Test Error", + StatusCode::UNPROCESSABLE_ENTITY, + ) + .detail("something was wrong"), + ) + .expect("problem serializes"); + + assert_eq!(value["type"], "https://example.com/problems/test"); + assert_eq!(value["title"], "Test Error"); + assert_eq!(value["status"], 422); + assert_eq!(value["detail"], "something was wrong"); + } + + #[test] + fn cors_rejects_wildcard() { + let policy = CorsPolicy { + allowed_origins: vec!["*".to_string()], + allowed_methods: Vec::new(), + allowed_headers: Vec::new(), + allow_credentials: false, + }; + assert!(matches!( + policy.validate(), + Err(CorsValidationError::WildcardOrigin) + )); + } + + #[test] + fn credentialed_cors_rejects_wildcard_headers() { + let policy = CorsPolicy { + allowed_origins: vec!["https://app.example.test".to_string()], + allowed_methods: Vec::new(), + allowed_headers: Vec::new(), + allow_credentials: true, + }; + assert!(matches!( + policy.validate(), + Err(CorsValidationError::CredentialedWildcardHeaders) + )); + } + + #[test] + #[should_panic(expected = "invalid CORS policy")] + #[allow(deprecated)] + fn credentialed_cors_layer_rejects_wildcard_headers() { + let _layer = CorsPolicy { + allowed_origins: vec!["https://app.example.test".to_string()], + allowed_methods: Vec::new(), + allowed_headers: Vec::new(), + allow_credentials: true, + } + .layer(); + } + + #[test] + fn cors_try_layer_returns_validation_error_instead_of_panicking() { + let err = CorsPolicy { + allowed_origins: vec!["*".to_string()], + allowed_methods: Vec::new(), + allowed_headers: Vec::new(), + allow_credentials: false, + } + .try_layer() + .expect_err("wildcard origin rejects"); + assert!(matches!(err, CorsValidationError::WildcardOrigin)); + } + + #[test] + fn hsts_and_cross_origin_isolation_helpers_set_headers_without_overwriting() { + let mut response = Response::new(()); + apply_hsts(&mut response, hsts_header(31_536_000, true, true)); + apply_cross_origin_isolation(&mut response, CrossOriginIsolation::RequireCorp); + + assert_eq!( + response.headers().get("strict-transport-security").unwrap(), + "max-age=31536000; includeSubDomains; preload" + ); + assert_eq!( + response + .headers() + .get("cross-origin-opener-policy") + .unwrap(), + "same-origin" + ); + assert_eq!( + response + .headers() + .get("cross-origin-embedder-policy") + .unwrap(), + "require-corp" + ); + + response.headers_mut().insert( + "cross-origin-embedder-policy", + HeaderValue::from_static("credentialless"), + ); + apply_cross_origin_isolation(&mut response, CrossOriginIsolation::RequireCorp); + assert_eq!( + response + .headers() + .get("cross-origin-embedder-policy") + .unwrap(), + "credentialless" + ); + } + + #[test] + fn request_body_limit_default_is_one_mebibyte() { + assert_eq!(DEFAULT_REQUEST_BODY_LIMIT_BYTES, 1024 * 1024); + let _layer = request_body_limit_default(); + } + + #[tokio::test] + async fn security_headers_install_shared_baseline_without_overwriting_csp() { + use tower::service_fn; + use tower::ServiceExt; + + let service = security_headers(CspBuilder::restrictive()).layer(service_fn( + |_request: Request| async { + let mut response = Response::new(Body::empty()); + response.headers_mut().insert( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static("default-src 'none'"), + ); + Ok::<_, std::convert::Infallible>(response) + }, + )); + + let response = service + .oneshot(Request::new(Body::empty())) + .await + .expect("security header service responds"); + let headers = response.headers(); + assert_eq!( + headers.get("content-security-policy"), + Some(&HeaderValue::from_static("default-src 'none'")) + ); + assert_eq!( + headers.get("x-content-type-options"), + Some(&HeaderValue::from_static("nosniff")) + ); + assert_eq!( + headers.get("referrer-policy"), + Some(&HeaderValue::from_static("no-referrer")) + ); + assert_eq!( + headers.get("x-frame-options"), + Some(&HeaderValue::from_static("DENY")) + ); + assert_eq!( + headers.get("permissions-policy"), + Some(&HeaderValue::from_static( + "camera=(), microphone=(), geolocation=(), payment=(), usb=(), browsing-topics=()" + )) + ); + assert_eq!( + headers.get("cross-origin-opener-policy"), + Some(&HeaderValue::from_static("same-origin")) + ); + // HTTPSEC-01: HSTS is included in the default baseline. + assert_eq!( + headers.get("strict-transport-security"), + Some(&HeaderValue::from_static(DEFAULT_HSTS_VALUE)) + ); + } + + // HTTPSEC-01: HSTS is present by default via security_headers(). + #[tokio::test] + async fn security_headers_emits_hsts_by_default() { + use tower::service_fn; + use tower::ServiceExt; + + let service = security_headers(CspBuilder::restrictive()).layer(service_fn( + |_request: Request| async { + Ok::<_, std::convert::Infallible>(Response::new(Body::empty())) + }, + )); + let response = service + .oneshot(Request::new(Body::empty())) + .await + .expect("service responds"); + assert_eq!( + response.headers().get("strict-transport-security"), + Some(&HeaderValue::from_static(DEFAULT_HSTS_VALUE)), + "HSTS must be present by default" + ); + } + + // HTTPSEC-01: without_hsts() opts out of the HSTS header. + #[tokio::test] + async fn security_headers_without_hsts_omits_hsts_header() { + use tower::service_fn; + use tower::ServiceExt; + + let service = security_headers(CspBuilder::restrictive()) + .without_hsts() + .layer(service_fn(|_request: Request| async { + Ok::<_, std::convert::Infallible>(Response::new(Body::empty())) + })); + let response = service + .oneshot(Request::new(Body::empty())) + .await + .expect("service responds"); + assert!( + response + .headers() + .get("strict-transport-security") + .is_none(), + "HSTS must be absent after without_hsts()" + ); + } + + #[test] + fn conditional_corp_matches_cors_response() { + let mut response = Response::new(()); + apply_conditional_corp(&mut response); + assert_eq!( + response + .headers() + .get("cross-origin-resource-policy") + .unwrap(), + "same-origin" + ); + + response.headers_mut().insert( + "access-control-allow-origin", + HeaderValue::from_static("https://example.test"), + ); + apply_conditional_corp(&mut response); + assert_eq!( + response + .headers() + .get("cross-origin-resource-policy") + .unwrap(), + "cross-origin" + ); + } +} diff --git a/crates/registry-platform-httpsec/tests/integration.rs b/crates/registry-platform-httpsec/tests/integration.rs index 075ea1fc2..177a79136 100644 --- a/crates/registry-platform-httpsec/tests/integration.rs +++ b/crates/registry-platform-httpsec/tests/integration.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "server")] + use axum::body::{to_bytes, Body}; use axum::http::{header, Method, Request, StatusCode}; use axum::response::IntoResponse; diff --git a/crates/registry-platform-httputil/Cargo.toml b/crates/registry-platform-httputil/Cargo.toml index 298aeeb22..5af80b37f 100644 --- a/crates/registry-platform-httputil/Cargo.toml +++ b/crates/registry-platform-httputil/Cargo.toml @@ -16,11 +16,14 @@ rustls = ["reqwest/rustls-tls"] test-support = [] [dependencies] +async-trait.workspace = true base64.workspace = true bytes.workspace = true +chrono.workspace = true hickory-resolver.workspace = true http.workspace = true ipnet.workspace = true +registry-platform-authcommon.workspace = true registry-platform-canonical-json.workspace = true registry-platform-crypto.workspace = true reqwest.workspace = true @@ -36,7 +39,11 @@ zeroize.workspace = true [dev-dependencies] axum.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true +p256.workspace = true proptest.workspace = true rcgen.workspace = true tokio = { workspace = true, features = ["io-util"] } tokio-rustls.workspace = true +wiremock.workspace = true diff --git a/crates/registry-platform-httputil/README.md b/crates/registry-platform-httputil/README.md index ad92759a9..04be0c71f 100644 --- a/crates/registry-platform-httputil/README.md +++ b/crates/registry-platform-httputil/README.md @@ -4,11 +4,20 @@ Outbound HTTP utilities for registry services. ## What It Provides -- `OutboundClientBuilder` with request and connect timeouts, no redirects, and - ignored proxy environment variables by default. It does not validate target - URLs; pair it with `FetchUrlPolicy` for user-controlled destinations. +- `OutboundClientBuilder::try_build` with explicit rustls, retries and redirects + disabled, ignored proxy environment variables, and optional exact CA pinning. + Pinned bundles are limited by + `MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES` and + `MAXIMUM_TRUSTED_ROOT_CERTIFICATES` before client construction. + Pair it with `ServiceBaseUrl` for configured credential-bearing services or + `FetchUrlPolicy` for user-controlled destinations. - `read_bounded` for response bodies with content-length and streaming byte limits. +- `ServiceBaseUrl`, bearer token providers, and `PrivateKeyJwt` for hardened + credential-bearing clients without product semantics. The private-key-JWT + provider preserves its closed `client_credentials` request shape. +- Shared strict response-header bounds and exact-one delta-seconds + `Retry-After` parsing. - `ProxyHeaderPolicy` plus request and response header filters for proxy-safe forwarding. - `url::append_path_segments` for safe path construction. diff --git a/crates/registry-platform-httputil/src/client/mod.rs b/crates/registry-platform-httputil/src/client/mod.rs new file mode 100644 index 000000000..5d469dcbe --- /dev/null +++ b/crates/registry-platform-httputil/src/client/mod.rs @@ -0,0 +1,181 @@ +//! Product-neutral primitives for outbound service clients. + +use std::{fmt, ops::Deref, time::Duration}; + +use thiserror::Error; +use url::Url; + +pub use crate::{MAXIMUM_TRUSTED_ROOT_CERTIFICATES, MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES}; + +mod outbound; +mod private_key_jwt; +mod token; + +pub use outbound::{ + base_url_without_userinfo, build_client, read_failure_kind, send_failure_kind, + transport_protects_the_credential, OutboundOptions, +}; +pub use private_key_jwt::{ + PrivateKeyJwt, PrivateKeyJwtConfig, DEFAULT_ASSERTION_LIFETIME_SECONDS, + DEFAULT_REFRESH_MARGIN_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, +}; +pub use token::{BearerToken, OAuthErrorCode, StaticToken, TokenError, TokenProvider}; + +/// Default total request timeout for credential-bearing service clients. +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// Default connection timeout for credential-bearing service clients. +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Coarse, value-free reason an HTTP exchange did not complete. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TransportKind { + #[error("connection setup failed")] + Connect, + #[error("the configured timeout elapsed")] + Timeout, + #[error("the exchange failed")] + Exchange, + #[error("the response body exceeded the configured maximum")] + ResponseTooLarge, +} + +impl TransportKind { + /// Stable machine-readable classification. + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Connect => "connect", + Self::Timeout => "timeout", + Self::Exchange => "exchange", + Self::ResponseTooLarge => "response_too_large", + } + } +} + +/// A service base URL validated for sending credentials. +#[derive(Clone, PartialEq, Eq)] +pub struct ServiceBaseUrl(Url); + +impl ServiceBaseUrl { + /// Validate a URL once, before any credential-bearing request can be built. + pub fn new(url: Url) -> Result { + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err(ServiceBaseUrlError::AuthorityOrSuffix); + } + if !transport_protects_the_credential(&url) { + return Err(ServiceBaseUrlError::UnprotectedTransport); + } + if let Some(mut segments) = url.path_segments().map(Iterator::peekable) { + while let Some(segment) = segments.next() { + if segment.is_empty() && segments.peek().is_some() { + return Err(ServiceBaseUrlError::EmptyPathSegment); + } + } + } + Ok(Self(url)) + } + + #[must_use] + pub fn as_url(&self) -> &Url { + &self.0 + } + + #[must_use] + pub fn into_url(self) -> Url { + self.0 + } + + /// Join a relative service path to the validated deployment prefix. + pub fn join(&self, path: &str) -> Result { + if path.is_empty() + || path.starts_with('/') + || path.contains(['?', '#']) + || path + .split('/') + .any(|segment| segment.is_empty() || matches!(segment, "." | "..")) + { + return Err(ServiceBaseUrlJoinError); + } + let mut base = self.0.clone(); + let mut segments = base + .path_segments_mut() + .map_err(|_| ServiceBaseUrlJoinError)?; + segments.pop_if_empty(); + for segment in path.split('/') { + segments.push(segment); + } + drop(segments); + Ok(base) + } +} + +impl Deref for ServiceBaseUrl { + type Target = Url; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Debug for ServiceBaseUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("ServiceBaseUrl") + .field(&base_url_without_userinfo(&self.0)) + .finish() + } +} + +/// Fixed, value-free reason a service base URL is unusable. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ServiceBaseUrlError { + #[error("the service base URL must carry no credentials, query, or fragment")] + AuthorityOrSuffix, + #[error("the service base URL must use HTTPS, or HTTP with a loopback host")] + UnprotectedTransport, + #[error( + "the service base URL path must carry no empty segment other than a trailing separator" + )] + EmptyPathSegment, +} + +/// Value-free reason a relative service path cannot be appended safely. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[error("the service path must be non-empty relative segments without query or fragment")] +pub struct ServiceBaseUrlJoinError; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_base_urls_preserve_prefixes_and_refuse_credential_leaks() { + let base = ServiceBaseUrl::new(Url::parse("https://registry.example/prefix").unwrap()) + .expect("the HTTPS base URL is accepted"); + assert_eq!( + base.join("v1/records").unwrap().as_str(), + "https://registry.example/prefix/v1/records" + ); + for path in ["", "/v1/records", "../records", "v1//records", "v1?secret"] { + assert!(base.join(path).is_err(), "{path}"); + } + for candidate in [ + "http://registry.example/", + "https://secret@registry.example/", + "https://registry.example/?secret=value", + "https://registry.example/a//b", + ] { + let error = ServiceBaseUrl::new(Url::parse(candidate).unwrap()) + .expect_err("the unsafe base URL is refused"); + assert!(!error.to_string().contains("secret")); + assert!(!format!("{error:?}").contains("secret")); + } + } +} diff --git a/crates/registry-platform-httputil/src/client/outbound.rs b/crates/registry-platform-httputil/src/client/outbound.rs new file mode 100644 index 000000000..a15e88285 --- /dev/null +++ b/crates/registry-platform-httputil/src/client/outbound.rs @@ -0,0 +1,121 @@ +//! The one rule set every outbound exchange in this crate is built from. +//! +//! Credential-bearing service and token exchanges both hand a secret to a host +//! the integrator named, so both are built here rather than from rule sets that +//! could drift apart. + +use std::{borrow::Cow, time::Duration}; + +use crate::BoundedReadError; +use url::Url; + +use super::TransportKind; + +/// The one host name a cleartext URL may carry. It is reserved for the loopback +/// interface, so a credential sent to it cannot leave the host. +const LOOPBACK_NAME: &str = "localhost"; + +/// What a caller may vary about an outbound client. Everything else is fixed by +/// [`build_client`]. +#[derive(Debug, Clone, Copy)] +pub struct OutboundOptions<'a> { + pub request_timeout: Duration, + pub connect_timeout: Duration, + pub user_agent: Option<&'a str>, + pub trusted_root_certificates: Option<&'a [u8]>, +} + +/// Build an outbound client. +/// +/// A failure is fixed text naming what about the options is unusable. Each +/// caller wraps it in its own error vocabulary, because the two exchanges report +/// configuration failures to the adopter under different types. +pub fn build_client(options: OutboundOptions<'_>) -> Result { + let mut builder = crate::OutboundClientBuilder::new() + .timeout(options.request_timeout) + .connect_timeout(options.connect_timeout); + if let Some(user_agent) = options.user_agent { + builder = builder.user_agent(user_agent); + } + if let Some(pem) = options.trusted_root_certificates { + builder = builder.trusted_root_certificates(pem); + } + builder.try_build().map_err(|error| error.reason()) +} + +/// Whether this URL's transport keeps a secret sent to it away from the network. +/// +/// A secret in cleartext is only acceptable when it cannot leave the host, which +/// is the local development and tutorial case. The accepted forms are the ones an +/// adopter types: either loopback numeric family, or the reserved name +/// `localhost`. Any other name is refused, because a name that happens to resolve +/// to a loopback address is still resolved off-host, and the answer can change. +pub fn transport_protects_the_credential(url: &Url) -> bool { + match url.scheme() { + "https" => true, + "http" => url.host().is_some_and(|host| match host { + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + url::Host::Domain(name) => name == LOOPBACK_NAME, + }), + _ => false, + } +} + +/// The service URL with userinfo, query, and fragment removed for diagnostics. +/// +/// Validation refuses a base URL carrying credentials, but diagnostic rendering +/// cannot rely on having been reached only after construction. +pub fn base_url_without_userinfo(base_url: &Url) -> Cow<'_, str> { + if base_url.username().is_empty() + && base_url.password().is_none() + && base_url.query().is_none() + && base_url.fragment().is_none() + { + return Cow::Borrowed(base_url.as_str()); + } + let mut stripped = base_url.clone(); + // Both setters refuse only a URL that cannot carry userinfo at all, and this + // point is reached only for a URL that carries some, so neither can refuse + // here. A refusal withholds the whole URL rather than rendering a credential. + if stripped.set_username("").is_err() || stripped.set_password(None).is_err() { + return Cow::Borrowed(""); + } + stripped.set_query(None); + stripped.set_fragment(None); + Cow::Owned(stripped.into()) +} + +/// Why a send failed, in the terms the caller can act on. +pub fn send_failure_kind(error: &reqwest::Error) -> TransportKind { + if error.is_timeout() { + TransportKind::Timeout + } else if error.is_connect() { + // TLS negotiation failures arrive here too. Separating them would mean + // reading a transport error chain whose text this crate must not copy + // into a diagnostic. + TransportKind::Connect + } else { + TransportKind::Exchange + } +} + +/// Why a bounded read failed, in the terms the caller can act on. +/// +/// The distinction matters most for a timeout, which is the likely failure: the +/// configured total timeout runs until the body finishes, so an answer that +/// starts and stalls elapses here rather than at connection setup. No part of the +/// underlying error text is copied into the reported failure. +pub fn read_failure_kind(error: &BoundedReadError) -> TransportKind { + match error { + BoundedReadError::ContentLengthExceeded { .. } + | BoundedReadError::BodyTooLarge { .. } + | BoundedReadError::LengthOverflow => TransportKind::ResponseTooLarge, + BoundedReadError::Transport(error) if error.is_timeout() => TransportKind::Timeout, + // The reader's error type is open, so a variant this crate does not know + // yet becomes the coarse exchange failure. It must never become a claim + // about the response size, which is the one thing an adopter would act on + // by raising their own bound. + _ => TransportKind::Exchange, + } +} diff --git a/crates/registry-platform-httputil/src/client/private_key_jwt.rs b/crates/registry-platform-httputil/src/client/private_key_jwt.rs new file mode 100644 index 000000000..a6b06f2f8 --- /dev/null +++ b/crates/registry-platform-httputil/src/client/private_key_jwt.rs @@ -0,0 +1,1770 @@ +//! Token acquisition with a signed client assertion. +//! +//! This is the OAuth 2.0 `client_credentials` grant with the `private_key_jwt` +//! client authentication method of RFC 7523 section 2.2: the client proves who it +//! is by signing a short-lived assertion with a key only it holds, so no shared +//! secret ever leaves the process or sits in a deployment's configuration. +//! +//! It is plain OAuth. Nothing here knows which authorization server it is talking +//! to, and the provider carries no claim, route, or vocabulary belonging to any +//! particular issuer. The request body carries only `grant_type`, +//! `client_assertion_type`, and `client_assertion`; a server that also requires a +//! scope, a resource indicator, or a body `client_id` on this grant must use a +//! custom [`TokenProvider`]. +//! +//! The assertion itself is built by +//! [`registry_platform_authcommon::client_assertion`]. Nothing else in the +//! stack calls that builder yet: `registry-mint`'s own caller tooling +//! (`crates/registry-mint/src/caller.rs`) signs a client assertion for testing +//! Mint's token endpoint, but it builds its own claims, header, and algorithm +//! mapping rather than reusing this one. What this module owns is the token +//! request that presents one and the credential it is exchanged for. +//! +//! # What is cached, and for how long +//! +//! An access token is reused until it has less life left than the refresh margin, +//! at which point the next caller acquires a replacement. The margin exists +//! because a credential that is valid when the request is built may have expired +//! by the time the deployment reads it. A server that states no lifetime has given +//! nothing to cache against, so each request acquires its own credential. The +//! deadline is measured against a reading that only moves forward, so correcting +//! the host clock cannot extend how long a credential is presented for. + +use std::{ + fmt, + sync::Arc, + time::{Duration, Instant}, +}; + +use crate::{read_bounded, validate_response_headers}; +use async_trait::async_trait; +use chrono::Utc; +use registry_platform_authcommon::client_assertion::{ + sign_client_assertion, ClientAssertionError, ClientAssertionRequest, +}; +use registry_platform_crypto::PrivateJwk; +use reqwest::header::{HeaderMap, ACCEPT, CONTENT_TYPE}; +use serde::Deserialize; +use tokio::sync::{Mutex, RwLock}; +use url::Url; +use zeroize::Zeroizing; + +use super::{ + outbound::{ + self, base_url_without_userinfo, transport_protects_the_credential, OutboundOptions, + }, + token::{BearerToken, OAuthErrorCode, TokenError, TokenProvider}, + DEFAULT_CONNECT_TIMEOUT, DEFAULT_REQUEST_TIMEOUT, +}; + +/// How long an assertion is good for by default, and the longest one this +/// provider will sign. +/// +/// Both bound the assertion rather than the token request it is presented on, +/// so they belong to the builder. They are re-exported because an integrator +/// configuring this provider is the one who has to stay inside them. +pub use registry_platform_authcommon::client_assertion::{ + DEFAULT_ASSERTION_LIFETIME_SECONDS, MAXIMUM_ASSERTION_LIFETIME_SECONDS, +}; + +/// What the constructor signs to prove the client key can sign at all. +/// +/// It carries a space and no `.`, so it cannot be read as the +/// `base64url(header).base64url(claims)` a client assertion is signed over. The +/// signature is discarded, and could not stand in for one even if it were not. +const CLIENT_KEY_PROBE: &[u8] = b"registry-platform-httputil client key usability probe"; + +/// How much of an access token's remaining life is treated as already spent. +pub const DEFAULT_REFRESH_MARGIN_SECONDS: i64 = 30; + +/// Longest an issuer's stated `expires_in` is trusted for, when deciding how +/// long to cache the credential it came with. +/// +/// `expires_in` is a remote-controlled value. An authorization server that +/// reports one far longer than any real access token lives, whether by a bug +/// or by intent, must not be able to keep a credential cached, and therefore +/// live in memory, for the life of the process with no way for the integrator +/// to evict it. Re-acquiring a token earlier than an issuer's stated lifetime +/// requires is always safe, so clamping to 86400 seconds (24 hours) cannot +/// break a correct deployment. +pub const MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS: i64 = 86_400; +// Ties the doc comment above to the constant, so the two cannot drift apart. +const _: () = assert!(MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS == 86_400); + +/// The grant this provider asks for. The client authenticates as itself, on its +/// own behalf, which is the closed grant this provider supports. +const GRANT_TYPE: &str = "client_credentials"; + +/// The client authentication method of RFC 7523 section 2.2. +const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; + +const FORM_MEDIA_TYPE: &str = "application/x-www-form-urlencoded"; +const JSON_MEDIA_TYPE: &str = "application/json"; + +/// The only token type an `Authorization: Bearer` request can present. Compared +/// without case, as RFC 6749 section 5.1 requires. +const BEARER_TOKEN_TYPE: &str = "bearer"; + +/// Longest token response this provider will read. A token response is a small +/// JSON object; anything larger is not one. +const MAXIMUM_TOKEN_RESPONSE_BYTES: u64 = 16 * 1024; + +/// The two readings the provider reasons about. +/// +/// They are separate because they answer different questions. An assertion claim +/// is a wall-clock time the authorization server checks against its own clock, +/// so nothing else will do there. A cache deadline is only ever compared against +/// a later reading of the same clock, and a wall-clock one would move whenever +/// the host clock is corrected. Both come from one source a test can drive, +/// rather than from readings of the host taken wherever they are needed. +pub(crate) trait Clock: Send + Sync { + fn unix_seconds(&self) -> i64; + + /// A reading that only ever moves forward, however the host clock is set. + fn monotonic(&self) -> Instant; +} + +/// The host clock. +struct SystemClock; + +impl Clock for SystemClock { + fn unix_seconds(&self) -> i64 { + Utc::now().timestamp() + } + + fn monotonic(&self) -> Instant { + Instant::now() + } +} + +/// What an integrator decides before the provider can authenticate. +pub struct PrivateKeyJwtConfig { + token_endpoint: Url, + client_id: String, + client_key: PrivateJwk, + audience: Option, + assertion_lifetime_seconds: i64, + refresh_margin_seconds: i64, + request_timeout: Duration, + connect_timeout: Duration, + user_agent: Option, + trusted_root_certificates: Option>>, +} + +impl PrivateKeyJwtConfig { + /// Authenticate as `client_id` at `token_endpoint`, signing with + /// `client_key`. + /// + /// `client_key` may sign with EdDSA, ES256, RS256, ES384, or RS384, and must + /// carry a key identifier: the identifier is how the authorization server + /// selects the registered public key to check the assertion against. The + /// assertion header names whichever of the five the key states, so the + /// server needs that algorithm among the ones it accepts. + #[must_use] + pub fn new(token_endpoint: Url, client_id: impl Into, client_key: PrivateJwk) -> Self { + Self { + token_endpoint, + client_id: client_id.into(), + client_key, + audience: None, + assertion_lifetime_seconds: DEFAULT_ASSERTION_LIFETIME_SECONDS, + refresh_margin_seconds: DEFAULT_REFRESH_MARGIN_SECONDS, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + user_agent: None, + trusted_root_certificates: None, + } + } + + /// State the assertion audience the authorization server expects. + /// + /// The default is the token endpoint URL, which is what RFC 7523 section 3 + /// recommends. Set this only when the server published a different value: an + /// assertion whose audience the server does not recognize is refused as an + /// authentication failure, with no indication of which claim was wrong. + /// + /// Must not be empty; an empty value is refused when the provider is built + /// rather than here. + #[must_use] + pub fn with_audience(mut self, audience: impl Into) -> Self { + self.audience = Some(audience.into()); + self + } + + /// Must be within `1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS`; an out-of-range + /// value is refused when the provider is built rather than here. + #[must_use] + pub fn with_assertion_lifetime_seconds(mut self, seconds: i64) -> Self { + self.assertion_lifetime_seconds = seconds; + self + } + + /// Treat this much of an access token's remaining life as already spent. + /// + /// Must not be negative; a negative value is refused when the provider is + /// built rather than here. + #[must_use] + pub fn with_refresh_margin_seconds(mut self, seconds: i64) -> Self { + self.refresh_margin_seconds = seconds; + self + } + + #[must_use] + pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + #[must_use] + pub fn with_connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = timeout; + self + } + + #[must_use] + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + /// Trust exactly these PEM-encoded certificate authorities for the token + /// endpoint's TLS certificate, instead of the platform's own store. + #[must_use] + pub fn with_trusted_root_certificates(mut self, pem_bundle: impl Into>) -> Self { + self.trusted_root_certificates = Some(Zeroizing::new(pem_bundle.into())); + self + } +} + +impl fmt::Debug for PrivateKeyJwtConfig { + /// The client key and the pinned certificate material are withheld, as is any + /// userinfo in the token endpoint. Only the operational choices and the public + /// identifiers are rendered. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateKeyJwtConfig") + .field( + "token_endpoint", + &base_url_without_userinfo(&self.token_endpoint), + ) + .field("client_id", &self.client_id) + .field("audience", &self.audience) + .field( + "assertion_lifetime_seconds", + &self.assertion_lifetime_seconds, + ) + .field("refresh_margin_seconds", &self.refresh_margin_seconds) + .field("request_timeout", &self.request_timeout) + .field("connect_timeout", &self.connect_timeout) + .field("user_agent", &self.user_agent) + .finish_non_exhaustive() + } +} + +/// An access token, and the monotonic instant it stops being worth presenting. +struct CachedToken { + token: BearerToken, + expires_at: Instant, +} + +/// A [`TokenProvider`] that authenticates with a signed assertion and caches what +/// it is issued. +pub struct PrivateKeyJwt { + http: reqwest::Client, + token_endpoint: Url, + client_id: String, + audience: String, + audience_is_token_endpoint: bool, + assertion_lifetime_seconds: i64, + refresh_margin_seconds: i64, + client_key: PrivateJwk, + key_id: String, + clock: Arc, + /// The credential in hand, if it is still worth presenting. + cached: RwLock>, + /// Held for the length of one token request, so concurrent callers wait for + /// that request instead of opening one each. + refresh_lock: Mutex<()>, +} + +impl PrivateKeyJwt { + /// Refuse a configuration that cannot authenticate, cannot protect its + /// assertion in transit, or cannot produce an assertion the server it + /// registered with can verify. + /// + /// Every one of these would otherwise fail once per request, as an + /// authentication refusal whose code says nothing about which part was wrong. + pub fn new(config: PrivateKeyJwtConfig) -> Result { + Self::with_clock(config, Arc::new(SystemClock)) + } + + pub(crate) fn with_clock( + config: PrivateKeyJwtConfig, + clock: Arc, + ) -> Result { + let refuse = |reason: &'static str| TokenError::Configuration { reason }; + + if config.client_id.trim().is_empty() { + return Err(refuse("the client identifier must not be empty")); + } + // Only a stated audience can be empty. A caller that stated none gets the + // token endpoint, which is a parsed URL and therefore never is. + if config + .audience + .as_ref() + .is_some_and(|audience| audience.trim().is_empty()) + { + return Err(refuse("the assertion audience must not be empty")); + } + if !config.token_endpoint.username().is_empty() + || config.token_endpoint.password().is_some() + || config.token_endpoint.fragment().is_some() + { + return Err(refuse( + "the token endpoint must carry no credentials or fragment", + )); + } + // The assertion authenticates the client, so it is as sensitive in transit + // as the access token it is exchanged for, and the same transport rule + // applies to both. + if !transport_protects_the_credential(&config.token_endpoint) { + return Err(refuse( + "the token endpoint must use HTTPS, or HTTP with a loopback host", + )); + } + // The assertion header names the algorithm so the server can verify + // without guessing, which means it must state what this key actually + // signs with rather than one fixed name. Restricting the client to a + // single algorithm would refuse keys a conforming authorization server + // accepts: `token_endpoint_auth_signing_alg_values_supported` is the + // server's choice to publish, not this client's to narrow. Parsing a + // `PrivateJwk` already refuses any algorithm the crypto crate does not + // support, so this arm is a floor rather than a path a caller can reach. + // Checking here rather than leaving it to the builder is what keeps the + // promise this constructor makes: a key that cannot produce an assertion + // is refused now, not once per request. + if config.client_key.algorithm().is_err() { + return Err(refuse( + "the client key must state a supported signing algorithm", + )); + } + let key_id = config + .client_key + .kid + .clone() + .filter(|kid| !kid.trim().is_empty()) + .ok_or_else(|| refuse("the client key must carry a key identifier"))?; + // Stating an algorithm is not the same as being able to sign with it. A + // P-256 scalar of zero and an RSA key whose components disagree are both + // well-formed enough to parse, and are rejected only where the key is + // imported, which is at signing time. Signing once here keeps the promise + // this constructor makes: a key that cannot sign is refused now rather + // than once per request, as an authentication failure that names nothing. + // EdDSA never reaches this, since every 32-byte string is a valid Ed25519 + // seed, which is why signing with EdDSA alone hid the gap. + // + // The probe is deliberately not shaped like a JWS signing input, so the + // signature it discards could not be presented as a client assertion. + let probe = registry_platform_crypto::sign(CLIENT_KEY_PROBE, &config.client_key) + .map_err(|_| refuse("the client key cannot sign a client assertion"))?; + // A JWK carrying one pair's `d` beside another pair's public fields + // produces assertions no server can verify: the public half an adopter + // registers is derived from those fields, so the server would see a + // valid signature over a key it was never given. Verifying the probe + // against this key's own public half is what proves the two belong + // together. + // + // ES256 and ES384 no longer reach this, because importing an EC pair + // compares the two halves and the signing probe above already refused + // the key. EdDSA, RS256, and RS384 import the private half alone and + // still sign happily, so the check stays. + registry_platform_crypto::verify(CLIENT_KEY_PROBE, &probe, &config.client_key.public()) + .map_err(|_| refuse("the client key's halves belong to different key pairs"))?; + // Ties the message below to the constant, so the constant cannot drift + // from the number the message states. + const _: () = assert!(MAXIMUM_ASSERTION_LIFETIME_SECONDS == 300); + if !(1..=MAXIMUM_ASSERTION_LIFETIME_SECONDS).contains(&config.assertion_lifetime_seconds) { + return Err(refuse( + "the assertion lifetime must be within 1..=300 seconds", + )); + } + if config.refresh_margin_seconds < 0 { + return Err(refuse("the refresh margin must not be negative")); + } + if config.request_timeout.is_zero() || config.connect_timeout.is_zero() { + return Err(refuse("the timeouts must be greater than zero")); + } + + let http = outbound::build_client(OutboundOptions { + request_timeout: config.request_timeout, + connect_timeout: config.connect_timeout, + user_agent: config.user_agent.as_deref(), + trusted_root_certificates: config + .trusted_root_certificates + .as_ref() + .map(|pem| pem.as_slice()), + }) + .map_err(refuse)?; + + let audience_is_token_endpoint = config.audience.is_none(); + Ok(Self { + http, + audience: config + .audience + .unwrap_or_else(|| config.token_endpoint.as_str().to_owned()), + audience_is_token_endpoint, + token_endpoint: config.token_endpoint, + client_id: config.client_id, + assertion_lifetime_seconds: config.assertion_lifetime_seconds, + refresh_margin_seconds: config.refresh_margin_seconds, + client_key: config.client_key, + key_id, + clock, + cached: RwLock::new(None), + refresh_lock: Mutex::new(()), + }) + } + + /// The cached credential, if it has more life left than the refresh margin. + /// + /// The deadline and `monotonic_now` are both readings of a clock that only + /// moves forward, so a host clock stepping backward cannot present a spent + /// credential as a fresh one. + async fn usable_cached_token(&self, monotonic_now: Instant) -> Option { + // A negative margin was refused at construction, so this is the margin + // the integrator configured. + let margin = Duration::from_secs(self.refresh_margin_seconds.unsigned_abs()); + let cached = self.cached.read().await; + cached + .as_ref() + .filter(|entry| entry.expires_at.saturating_duration_since(monotonic_now) > margin) + .map(|entry| entry.token.clone()) + } + + /// One client assertion, valid from `now` for the configured lifetime. + fn sign_assertion(&self, now: i64) -> Result, TokenError> { + sign_client_assertion( + &self.client_key, + &ClientAssertionRequest { + client_id: &self.client_id, + audience: &self.audience, + lifetime_seconds: self.assertion_lifetime_seconds, + issued_at: now, + }, + ) + .map_err(assertion_refusal) + } + + /// Exchange one fresh assertion for an access token. + /// + /// `now` dates the assertion the authorization server validates, and + /// `monotonic_now` is what the cache deadline of whatever it issues is + /// measured from. + async fn acquire(&self, now: i64, monotonic_now: Instant) -> Result { + let assertion = self.sign_assertion(now)?; + // The assertion is a credential, so it lives in a scrubbed buffer here. + // The body reqwest owns afterwards cannot be wiped, which is why the + // assertion is single use and its lifetime is bounded. + let body = Zeroizing::new( + url::form_urlencoded::Serializer::new(String::new()) + .append_pair("grant_type", GRANT_TYPE) + .append_pair("client_assertion_type", CLIENT_ASSERTION_TYPE) + .append_pair("client_assertion", &assertion) + .finish(), + ); + + let response = self + .http + .post(self.token_endpoint.clone()) + .header(CONTENT_TYPE, FORM_MEDIA_TYPE) + .header(ACCEPT, JSON_MEDIA_TYPE) + .body(body.as_str().to_owned()) + .send() + .await + .map_err(|error| TokenError::Transport { + kind: outbound::send_failure_kind(&error), + })?; + + let status = response.status().as_u16(); + if validate_response_headers(response.headers()).is_err() { + return Err(TokenError::Protocol { status }); + } + let media_type = exact_content_type(response.headers()).map(str::to_owned); + let body = match read_bounded(response, MAXIMUM_TOKEN_RESPONSE_BYTES).await { + // The response carries a credential, so the buffer it was read into is + // wiped when this exchange ends. + Ok(body) => Zeroizing::new(body), + // The status arrived before the body did, and for a refusal it is the + // whole of what this crate would have reported anyway. + Err(_) if !(200..300).contains(&status) => return Err(TokenError::Protocol { status }), + Err(error) => { + return Err(TokenError::Transport { + kind: outbound::read_failure_kind(&error), + }) + } + }; + + if !(200..300).contains(&status) { + return Err(declined(status, media_type.as_deref(), &body)); + } + if status != 200 + || !media_type + .as_deref() + .is_some_and(|value| essence(value).eq_ignore_ascii_case(JSON_MEDIA_TYPE)) + { + return Err(TokenError::Protocol { status }); + } + let Ok(issued) = serde_json::from_slice::(&body) else { + return Err(TokenError::Protocol { status }); + }; + if !issued.token_type.eq_ignore_ascii_case(BEARER_TOKEN_TYPE) { + return Err(TokenError::Protocol { status }); + } + Ok(AcquiredToken { + // Moved rather than copied, so the credential ends up in the buffer + // `BearerToken` wipes on drop. + token: BearerToken::new(issued.access_token)?, + // A stated lifetime is what makes caching possible. Without one, or + // with one already elapsed, the credential is used once and dropped. + // A lifetime longer than this provider will trust is clamped before + // it ever reaches the cache arithmetic below. + expires_at: issued + .expires_in + .filter(|seconds| *seconds > 0) + .map(|seconds| seconds.min(MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS)) + // What reaches this point is within + // 1..=MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, so the deadline is + // an instant at most a day ahead of the reading it is built on. + .map(|seconds| monotonic_now + Duration::from_secs(seconds.unsigned_abs())), + }) + } +} + +#[async_trait] +impl TokenProvider for PrivateKeyJwt { + async fn bearer_token(&self) -> Result { + if let Some(token) = self.usable_cached_token(self.clock.monotonic()).await { + return Ok(token); + } + // The refresh lock serializes acquisition: one caller holds it and + // performs a token request while the rest wait their turn for it. The + // freshness check below spares a waiter its own request only when the + // caller ahead of it cached something; a credential that could not be + // cached, or a failed request, sends the next waiter to acquire its own + // in turn. A wait is therefore bounded by the number of callers ahead of + // it times the configured request timeout, not by that timeout alone. + let _refreshing = self.refresh_lock.lock().await; + let now = self.clock.unix_seconds(); + let monotonic_now = self.clock.monotonic(); + if let Some(token) = self.usable_cached_token(monotonic_now).await { + return Ok(token); + } + + let acquired = self.acquire(now, monotonic_now).await?; + let mut cached = self.cached.write().await; + // An uncacheable credential clears the cache rather than leaving a stale + // entry behind it. + *cached = acquired.expires_at.map(|expires_at| CachedToken { + token: acquired.token.clone(), + expires_at, + }); + Ok(acquired.token) + } +} + +impl fmt::Debug for PrivateKeyJwt { + /// The client key and the cached credential are withheld. What is rendered is + /// what an operator needs to recognize which provider this is. + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateKeyJwt") + .field( + "token_endpoint", + &base_url_without_userinfo(&self.token_endpoint), + ) + .field("client_id", &self.client_id) + .field( + "audience", + &if self.audience_is_token_endpoint { + base_url_without_userinfo(&self.token_endpoint) + } else { + std::borrow::Cow::Borrowed(self.audience.as_str()) + }, + ) + .field( + "assertion_lifetime_seconds", + &self.assertion_lifetime_seconds, + ) + .field("refresh_margin_seconds", &self.refresh_margin_seconds) + .field("key_id", &self.key_id) + .finish_non_exhaustive() + } +} + +/// A credential and what may be assumed about how long it lasts. +struct AcquiredToken { + token: BearerToken, + expires_at: Option, +} + +/// The success response of RFC 6749 section 5.1, in the members this client uses. +#[derive(Deserialize)] +struct IssuedToken { + access_token: String, + token_type: String, + expires_in: Option, +} + +/// The error response of RFC 6749 section 5.2. +/// +/// Only the code is read. `error_description` and `error_uri` are server-authored +/// text about a failed authentication attempt, so they stay in the buffer this +/// exchange is about to drop. +#[derive(Deserialize)] +struct DeclinedToken { + error: String, +} + +/// Report a refusal from the assertion builder in this provider's own words. +/// +/// Every refusal the builder makes about its inputs was ruled out when the +/// provider was built, and the one it makes about its own output cannot happen +/// for a claim set of strings and numbers, so reaching any of them means a +/// configuration that passed those checks still cannot produce an assertion. It +/// stays an explicit failure rather than a retry, because no later request would +/// sign either. The match is exhaustive so a refusal added to the builder has to +/// be given a reason here, rather than arriving as one the adopter cannot act +/// on. +fn assertion_refusal(error: ClientAssertionError) -> TokenError { + let reason = match error { + ClientAssertionError::EmptyClientId => "the client identifier must not be empty", + ClientAssertionError::EmptyAudience => "the assertion audience must not be empty", + ClientAssertionError::MissingKeyId => "the client key must carry a key identifier", + ClientAssertionError::UnsupportedAlgorithm => { + "the client key must state a supported signing algorithm" + } + ClientAssertionError::LifetimeOutOfRange => { + "the assertion lifetime must be within 1..=300 seconds" + } + ClientAssertionError::NotSerializable => "the client assertion cannot be serialized", + ClientAssertionError::CannotSign => "the client key cannot sign a client assertion", + }; + TokenError::Configuration { reason } +} + +/// Map a refused token request onto the code it reported. +/// +/// RFC 6749 section 5.2 puts a decision about the client at 400, and an +/// authentication failure at 401. Any other status is the server reporting +/// something about itself, which is not a statement this client can act on as a +/// refusal. A body is read as a refusal only when it arrives in the media type +/// the request asked for; an intermediary answering in some other media type, +/// or none at all, never reached the authorization server's own refusal logic, +/// so it is reported as a protocol failure instead. +fn declined(status: u16, media_type: Option<&str>, body: &[u8]) -> TokenError { + if !matches!(status, 400 | 401) + || !media_type.is_some_and(|value| essence(value).eq_ignore_ascii_case(JSON_MEDIA_TYPE)) + { + return TokenError::Protocol { status }; + } + match serde_json::from_slice::(body) { + Ok(declined) => TokenError::Refused { + code: OAuthErrorCode::from_wire(&declined.error), + }, + Err(_) => TokenError::Protocol { status }, + } +} + +/// The media type without its parameters. +fn essence(value: &str) -> &str { + value.split(';').next().unwrap_or_default().trim() +} + +fn exact_content_type(headers: &HeaderMap) -> Option<&str> { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + match (values.next(), values.next()) { + (Some(value), None) => value.to_str().ok(), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::{ + net::TcpListener, + sync::{ + atomic::{AtomicI64, AtomicU64, Ordering}, + Arc, + }, + time::{Duration, Instant}, + }; + + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; + use ed25519_dalek::SigningKey; + use registry_platform_crypto::{verify, PrivateJwk, PublicJwk}; + use serde_json::{json, Value}; + use url::Url; + use wiremock::{ + matchers::{body_string_contains, header, method, path}, + Mock, MockServer, ResponseTemplate, + }; + + use super::*; + use crate::client::{OAuthErrorCode, TokenError, TokenProvider, TransportKind}; + + #[test] + fn token_response_content_type_must_appear_exactly_once() { + let mut headers = HeaderMap::new(); + assert_eq!(exact_content_type(&headers), None); + headers.append(CONTENT_TYPE, "application/json".parse().expect("header")); + assert_eq!(exact_content_type(&headers), Some("application/json")); + headers.append(CONTENT_TYPE, "application/json".parse().expect("header")); + assert_eq!(exact_content_type(&headers), None); + } + + /// The instant the offline assertions in this module are centered on. + const NOW: i64 = 1_785_000_000; + const CLIENT_ID: &str = "urn:example:client:relying-party"; + const KEY_ID: &str = "client-key-2026-01"; + const TOKEN_PATH: &str = "/token"; + /// A credential text no server in this module ever varies, so a test can + /// assert on which credential a caller received. + const ISSUED_CREDENTIAL: &str = "issued-access-token"; + const TOKEN_LIFETIME_SECONDS: i64 = 300; + + /// A clock a test moves by hand, so cache arithmetic is asserted rather than + /// waited out. + /// + /// Both readings move together under [`TestClock::set`], as they do on a host + /// whose clock nothing is correcting. A test that needs them to disagree, + /// which is what a correction looks like, moves the wall reading on its own. + struct TestClock { + unix_seconds: AtomicI64, + unix_origin: i64, + monotonic_origin: Instant, + monotonic_elapsed_seconds: AtomicU64, + } + + impl TestClock { + fn new(now: i64) -> Self { + Self { + unix_seconds: AtomicI64::new(now), + unix_origin: now, + monotonic_origin: Instant::now(), + monotonic_elapsed_seconds: AtomicU64::new(0), + } + } + + /// Both readings are now at `now`, which is what time passing looks like. + fn set(&self, now: i64) { + self.unix_seconds.store(now, Ordering::Relaxed); + let elapsed = u64::try_from(now - self.unix_origin) + .expect("a test moves this clock forward from where it started"); + self.monotonic_elapsed_seconds + .store(elapsed, Ordering::Relaxed); + } + + /// Move the wall reading back, leaving the monotonic reading where it is. + /// That is what an NTP correction, a virtual machine resume, or an + /// operator setting the clock by hand does to a running process. + fn step_wall_clock_backward(&self, seconds: i64) { + self.unix_seconds.fetch_sub(seconds, Ordering::Relaxed); + } + } + + impl Clock for TestClock { + fn unix_seconds(&self) -> i64 { + self.unix_seconds.load(Ordering::Relaxed) + } + + fn monotonic(&self) -> Instant { + self.monotonic_origin + + Duration::from_secs(self.monotonic_elapsed_seconds.load(Ordering::Relaxed)) + } + } + + /// A fresh EdDSA client key, generated here so no test carries key material + /// in the tree. + fn client_key(key_id: Option<&str>) -> PrivateJwk { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("the test host supplies randomness"); + let key = SigningKey::from_bytes(&seed); + let mut document = json!({ + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "x": URL_SAFE_NO_PAD.encode(key.verifying_key().to_bytes()), + "d": URL_SAFE_NO_PAD.encode(key.to_bytes()), + }); + if let Some(key_id) = key_id { + document["kid"] = json!(key_id); + } + PrivateJwk::parse(&document.to_string()).expect("the test key parses") + } + + /// A fresh ES256 client key in the shape adopter tooling writes. + fn es256_client_key(key_id: Option<&str>) -> PrivateJwk { + let key = p256::ecdsa::SigningKey::random(&mut p256::elliptic_curve::rand_core::OsRng); + let point = key.verifying_key().to_encoded_point(false); + let mut document = json!({ + "kty": "EC", + "crv": "P-256", + "alg": "ES256", + "x": URL_SAFE_NO_PAD.encode(point.x().expect("an uncompressed P-256 point has x")), + "y": URL_SAFE_NO_PAD.encode(point.y().expect("an uncompressed P-256 point has y")), + "d": URL_SAFE_NO_PAD.encode(key.to_bytes()), + }); + if let Some(key_id) = key_id { + document["kid"] = json!(key_id); + } + PrivateJwk::parse(&document.to_string()).expect("the test key parses") + } + + /// A test-only 2048-bit RSA client key. RSA keys are too slow to generate per + /// test and the workspace carries no RSA generator, so unlike the EdDSA and + /// ES256 keys above this one is a constant. It is the same test-only key + /// `registry-platform-crypto` already pins for its RS256 tests, so it puts no + /// new key material in the tree. It authenticates nothing. + const RSA_CLIENT_JWK: &str = r#"{"kty":"RSA","kid":"registry-platform-client-rs256-test","alg":"RS256","n":"yIgEn3IXWI3CRyUY0gvZ-kJ55EC36MRFvj-ICsitN1-50phRS4CKMBRwbHwjgeTkbMDndOCmVfIbyKhJjOMIPxAzIHeMn9oWj5i-s8nlSgjHZpvCTnRbwZhbq6mEVoHJliX36IfV_iUopcwSL5lPd2wZmJ-msUmZFs6CTRExu0JGUJScOwFO5dqxBwiKyh7yGEPXI3u4tc3_47SZYxyde7fb-o3wl2RBJ28upa2jVRP9r-WjOGjE6tbZ35HnVUY4ECdYWzsiotg_XA9QVWa-pAKXV2Flr-gocCQ9E2qrSYjEbNXuFjPtMnuL6AHi0o5PiwT1dllcl925hpKd7Xt60w","e":"AQAB","d":"ATDtMhpe_z1-GTUV7NLO3V_Z0kb8W1YXkC7JbJTAdcE-FdKJrtu84Q87WpxG0tPcutFPLqW12QAQp2fbmxhZ6VrfVYneeOlEjO14ukqM_g35Z-eRDmYhwoFYrEWGqlH9XrZysHhKFZyKHW_G0lJV-Ks8Na_RFNNIXeVedVMQiytAFXibTHvdAdIrBGtt0M4tlQOCeRwnuoAQU-a5VB7rKGpxnJtUA7F_jjeX6jQPnUhkOXs20pPRey-i-jxwBbsF4XijHgTnGwAo5uOoY9b0kOmOb3Hs5TVqZCb3a4JoYAqZBbWrkKxccJTGMqLHCe0MBgQzKqP5KyrHRgQdzlmTnQ","p":"5xhkHe5lD7tUYJAFffHiRpy4unHfKDvTEASu8RBgWvHP2Hu5XLQU5n6DvI47LsW42swTcT6Ce1pWB2LK3SjKcw9FPEEGg8m5-tmfixaRq4DBaK0hj17763HmnYR0eQC0n_5y-My8WSC1y80T-AhKHJ_3xTtLXQd5Z9bf9MEiKS8","q":"3iRoiwbnn8oRJMjZUZhqKB-GVa7AJV0SUqXiUsBAJnqtbhuIESbkJKpt5eULeUQgdNkoG65KD-jXFUipWX1zlentc1FliCaB46jntqtxUsui8LNwKw_eb3nujQO7H1He4NJ5pfaLfRcmBOLwB-u2Z1cxrRDWhIgiHtGaAdQ7F50","dp":"j4h9vn1wNbozaRpq3tPap-L1dY_-e93UdPGDuuRiBHqGjr4h3itXg-X2aqmopp9V9kekl8SshHMSVdoNiBmqzJYieY8lvbsQkXaTem8VIQGCn0JRQtxK-eyvwQwgz3sZtPn0bQW0wmLnp2KD0Z1McsUEvnLalzhqNo2mYj2Guy8","dq":"0T6ySuLCIz2PUHrwWW-b7xdizirBS3CT5c3jldcJljVQT7sXPDDKDc-LnVVWrW-Csw4qPYi6sqm8j4vWGTmWOswSouE1Jj4_c1aSjPqI0FiIrvoW2jkkaRUNoz60cBgKPPOFKtNFKRs48LljJ9LcChOT81U8-7HPkgAVdUuYLfE","qi":"PnMeCE0dvWDLp2Dn1wsxtl-a0qjpkT9cp8EkvHYjCvVqqWqrVv84CoEo-1wA9j_VDvCG6T4n0UO9K0jfBf5yvPnahSQCLJk2nw-2uZ9YzBZKwkm21wU6hTknPst5Vk5ZbYJmzqXsCqEB5T2Bn5vqeXMe3SOB5hD2CbTFFfp3TC4"}"#; + + fn rs256_client_key() -> PrivateJwk { + PrivateJwk::parse(RSA_CLIENT_JWK).expect("the test key parses") + } + + /// A test-only P-384 client key. It restates the key material + /// `registry-platform-authcommon` and `registry-platform-crypto` already pin + /// for their own ES384 tests under a client `kid`, so it puts no new key + /// material in the tree. It authenticates nothing. + const P384_CLIENT_JWK: &str = r#"{"kty":"EC","crv":"P-384","d":"Cp2oq8BnIF6oQ2KWV-1yiR7Mf0rFOuDZ5nvS9E_9HGEODI76izZiDEFQ5kfSwCAg","x":"TH-XDvwYtzdc43QDOiBjfdQZTCx1k9Rz5ELDu_2NS8JWcCv8HlfK0T9rYijDIcAY","y":"eLx0gh3VmCC2DeubmC0CdDgno7aEBYEkz5Legyg-2GoLlFohSIop3zKCGSjhg7Ta","alg":"ES384","kid":"client-key-es384-2026-01"}"#; + + fn es384_client_key() -> PrivateJwk { + PrivateJwk::parse(P384_CLIENT_JWK).expect("the test key parses") + } + + /// The pinned RSA key restated under `alg`, which is the only difference + /// between an RS256 and an RS384 RSA JWK. + fn rs384_client_key() -> PrivateJwk { + let mut key = rs256_client_key(); + key.alg = Some("RS384".to_owned()); + key + } + + /// An ES256 key that parses and states its algorithm, yet cannot sign: zero + /// is a well-formed 32-byte scalar and an invalid P-256 private key. + /// + /// There is no EdDSA counterpart, because every 32-byte string is a valid + /// Ed25519 seed. That asymmetry is why signing with EdDSA alone never + /// exposed the gap this case covers. + fn unsignable_es256_client_key() -> PrivateJwk { + let mut key = es256_client_key(Some(KEY_ID)); + key.d = Some(URL_SAFE_NO_PAD.encode([0u8; 32])); + key + } + + /// An RS256 key that parses and states its algorithm, yet cannot sign: each + /// component is well-formed on its own, but `p` no longer divides `n`. + fn unsignable_rs256_client_key() -> PrivateJwk { + let mut key = rs256_client_key(); + key.p = key.q.clone(); + key + } + + /// An EdDSA key whose two halves belong to different key pairs. It signs, + /// and nothing it signs verifies against the public half an adopter would + /// register from this same document. + fn mismatched_eddsa_client_key() -> PrivateJwk { + let mut key = client_key(Some(KEY_ID)); + key.x = client_key(None).x.clone(); + key + } + + /// The ES256 counterpart: `d` from one pair, `x` and `y` from another. + /// + /// Unlike its EdDSA sibling this one never signs, since importing a P-256 + /// pair compares the halves. It is refused as a key that cannot sign rather + /// than as one whose probe fails to verify. + fn mismatched_es256_client_key() -> PrivateJwk { + let mut key = es256_client_key(Some(KEY_ID)); + let other = es256_client_key(None); + key.x = other.x.clone(); + key.y = other.y.clone(); + key + } + + fn endpoint(base: &str) -> Url { + format!("{base}{TOKEN_PATH}") + .parse() + .expect("the token endpoint parses") + } + + fn config(token_endpoint: Url, client_key: PrivateJwk) -> PrivateKeyJwtConfig { + PrivateKeyJwtConfig::new(token_endpoint, CLIENT_ID, client_key) + } + + /// A provider on a test clock, against a token endpoint that answers with one + /// credential. + fn provider(token_endpoint: Url, clock: &Arc) -> PrivateKeyJwt { + PrivateKeyJwt::with_clock( + config(token_endpoint, client_key(Some(KEY_ID))), + clock.clone(), + ) + .expect("the provider is usable as configured") + } + + /// The token response a compliant authorization server returns. + fn issued(expires_in: Option) -> ResponseTemplate { + let mut body = json!({ + "access_token": ISSUED_CREDENTIAL, + "token_type": "Bearer", + }); + if let Some(expires_in) = expires_in { + body["expires_in"] = json!(expires_in); + } + ResponseTemplate::new(200).set_body_json(body) + } + + async fn token_endpoint_serving(response: ResponseTemplate) -> MockServer { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(TOKEN_PATH)) + .and(header("content-type", "application/x-www-form-urlencoded")) + .respond_with(response) + .mount(&server) + .await; + server + } + + async fn token_requests(server: &MockServer) -> usize { + server + .received_requests() + .await + .expect("the mock server records its requests") + .len() + } + + fn parts(assertion: &str) -> (Value, Value, Vec) { + let segments: Vec<&str> = assertion.split('.').collect(); + assert_eq!(segments.len(), 3, "an assertion carries three segments"); + let decode = |segment: &str| { + let bytes = URL_SAFE_NO_PAD + .decode(segment) + .expect("the segment is base64url"); + serde_json::from_slice::(&bytes).expect("the segment carries JSON") + }; + let signature = URL_SAFE_NO_PAD + .decode(segments[2]) + .expect("the signature is base64url"); + (decode(segments[0]), decode(segments[1]), signature) + } + + fn signing_input(assertion: &str) -> &str { + let boundary = assertion + .rfind('.') + .expect("an assertion carries three segments"); + &assertion[..boundary] + } + + /// RFC 7523 section 2.2 fixes the claim set the token endpoint reads. The + /// header names the key so the server can select it without guessing. + #[test] + fn an_assertion_carries_the_claims_the_token_endpoint_requires() { + let key = client_key(Some(KEY_ID)); + let public: PublicJwk = key.public(); + let secret = key + .d + .clone() + .expect("the test key carries private material"); + let token_endpoint = endpoint("https://tokens.example.org"); + let clock = Arc::new(TestClock::new(NOW)); + let provider = + PrivateKeyJwt::with_clock(config(token_endpoint.clone(), key), clock.clone()) + .expect("the provider is usable as configured"); + + let assertion = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + let (header, claims, signature) = parts(&assertion); + + assert_eq!(header, json!({"alg": "EdDSA", "typ": "JWT", "kid": KEY_ID})); + assert_eq!(claims["iss"], json!(CLIENT_ID)); + assert_eq!(claims["sub"], json!(CLIENT_ID)); + assert_eq!(claims["aud"], json!(token_endpoint.as_str())); + assert_eq!(claims["iat"], json!(NOW)); + assert_eq!( + claims["exp"], + json!(NOW + DEFAULT_ASSERTION_LIFETIME_SECONDS) + ); + assert_eq!( + claims["jti"] + .as_str() + .expect("the assertion carries a jti") + .len(), + 26, + "the jti is a ULID" + ); + let members: std::collections::BTreeSet<&str> = claims + .as_object() + .expect("the claims are an object") + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + members, + ["aud", "exp", "iat", "iss", "jti", "sub"] + .into_iter() + .collect(), + "the assertion carries exactly the claims the profile fixes" + ); + verify(signing_input(&assertion).as_bytes(), &signature, &public) + .expect("the assertion verifies under the client key"); + assert!( + !assertion.contains(&secret), + "the assertion carries the private key" + ); + } + + /// The header must name the algorithm the key actually signs with, for every + /// algorithm the stack registers. A server selects the verification algorithm + /// from this header, so a fixed `alg` would either refuse the key outright or + /// present a signature under a name that does not describe it. + /// + /// ES256 is the case an adopter meets first: it is what adopter tooling writes + /// for a locally generated client key. + #[test] + fn an_assertion_names_the_algorithm_the_client_key_states() { + for (expected_alg, key) in [ + ("EdDSA", client_key(Some(KEY_ID))), + ("ES256", es256_client_key(Some(KEY_ID))), + ("RS256", rs256_client_key()), + ("ES384", es384_client_key()), + ("RS384", rs384_client_key()), + ] { + let key_id = key.kid.clone().expect("the test key carries a kid"); + let public: PublicJwk = key.public(); + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock( + config(endpoint("https://tokens.example.org"), key), + clock, + ) + .unwrap_or_else(|error| { + panic!("a {expected_alg} client key is usable as configured: {error}") + }); + + let assertion = provider + .sign_assertion(NOW) + .unwrap_or_else(|error| panic!("the {expected_alg} assertion is signed: {error}")); + let (header, _, signature) = parts(&assertion); + + assert_eq!( + header, + json!({"alg": expected_alg, "typ": "JWT", "kid": key_id}) + ); + verify(signing_input(&assertion).as_bytes(), &signature, &public).unwrap_or_else( + |error| { + panic!("the {expected_alg} assertion verifies under the client key: {error}") + }, + ); + } + } + + /// A replay-checking token endpoint refuses a repeated `jti`, so a fresh one + /// per request is what makes a second token request possible at all. + #[test] + fn every_assertion_gets_its_own_jti() { + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint("https://tokens.example.org"), &clock); + + let first = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + let second = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + + let (_, first_claims, _) = parts(&first); + let (_, second_claims, _) = parts(&second); + assert_ne!(first_claims["jti"], second_claims["jti"]); + assert_eq!(first_claims["iat"], second_claims["iat"]); + } + + /// A deployment whose token endpoint expects an audience of its own name says + /// so, and the default is the endpoint URL. + #[test] + fn the_assertion_audience_can_be_overridden() { + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock( + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_audience("https://tokens.example.org/"), + clock.clone(), + ) + .expect("the provider is usable as configured"); + + let assertion = provider + .sign_assertion(NOW) + .expect("the assertion is signed"); + let (_, claims, _) = parts(&assertion); + assert_eq!(claims["aud"], json!("https://tokens.example.org/")); + } + + /// The request the token endpoint receives is the form-encoded grant the + /// profile fixes, and it carries the assertion rather than a secret. + #[tokio::test] + async fn the_token_request_states_the_grant_and_the_authentication_method() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path(TOKEN_PATH)) + .and(header("content-type", "application/x-www-form-urlencoded")) + .and(header("accept", "application/json")) + .and(body_string_contains("grant_type=client_credentials")) + .and(body_string_contains( + "client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer", + )) + .and(body_string_contains("client_assertion=")) + .respond_with(issued(Some(TOKEN_LIFETIME_SECONDS))) + .mount(&server) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + let token = provider + .bearer_token() + .await + .expect("the token endpoint issued a credential"); + + assert_eq!(token.expose(), ISSUED_CREDENTIAL); + assert_eq!(token_requests(&server).await, 1); + } + + /// A credential is reused while it has more life left than the refresh margin, + /// and a caller arriving inside the margin gets a fresh one instead of a + /// credential that may expire in flight. + #[tokio::test] + async fn a_cached_credential_is_reused_until_the_refresh_margin() { + let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + provider.bearer_token().await.expect("a first credential"); + assert_eq!(token_requests(&server).await, 1); + + // Well inside the cached lifetime. + clock.set(NOW + TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS - 1); + provider + .bearer_token() + .await + .expect("the cached credential"); + assert_eq!( + token_requests(&server).await, + 1, + "a usable cached credential was discarded" + ); + + // The first instant inside the refresh margin. + clock.set(NOW + TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS); + provider + .bearer_token() + .await + .expect("a replacement credential"); + assert_eq!( + token_requests(&server).await, + 2, + "a credential inside the refresh margin was reused" + ); + } + + /// A host clock steps backward for ordinary reasons: an NTP correction, a + /// virtual machine resuming, an operator setting it by hand. A credential + /// whose deadline was a wall-clock time would look fresh again for as long as + /// the step was wide, and the provider would keep presenting a credential the + /// authorization server has already expired. + #[tokio::test] + async fn a_cached_credential_is_not_reused_after_the_host_clock_steps_backward() { + let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + provider.bearer_token().await.expect("a first credential"); + assert_eq!(token_requests(&server).await, 1); + + // The whole stated lifetime really elapsed, so the credential is spent. + clock.set(NOW + TOKEN_LIFETIME_SECONDS); + // Then the host clock is corrected far enough backward that its reading + // is before the credential was issued at all. + clock.step_wall_clock_backward(TOKEN_LIFETIME_SECONDS * 2); + assert!( + clock.unix_seconds() < NOW, + "the correction lands before the credential was issued" + ); + + provider + .bearer_token() + .await + .expect("a replacement credential"); + assert_eq!( + token_requests(&server).await, + 2, + "a spent credential was replayed after the host clock stepped backward" + ); + } + + /// A stated lifetime the issuer never bounded, such as `i64::MAX`, must not + /// keep a credential cached for the life of the process. The provider clamps + /// it to its own configured maximum before caching. + #[tokio::test] + async fn an_unbounded_stated_lifetime_is_clamped_to_the_configured_maximum() { + let server = token_endpoint_serving(issued(Some(i64::MAX))).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + provider.bearer_token().await.expect("a first credential"); + assert_eq!(token_requests(&server).await, 1); + + // Well inside the clamped lifetime, despite the issuer stating an + // effectively unbounded one. + clock.set(NOW + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS - 1); + provider + .bearer_token() + .await + .expect("the cached credential"); + assert_eq!( + token_requests(&server).await, + 1, + "a usable cached credential was discarded" + ); + + // The first instant inside the refresh margin of the clamped lifetime. + clock.set(NOW + MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS - DEFAULT_REFRESH_MARGIN_SECONDS); + provider + .bearer_token() + .await + .expect("a replacement credential"); + assert_eq!( + token_requests(&server).await, + 2, + "an unbounded stated lifetime was cached past the configured maximum" + ); + } + + /// A server that states no lifetime, or one that is already zero or + /// negative, has told the client nothing it may cache against, so every + /// request acquires its own credential rather than writing an unusable one + /// into the cache. + #[tokio::test] + async fn a_credential_without_a_stated_lifetime_is_not_cached() { + for expires_in in [None, Some(0), Some(-1)] { + let server = token_endpoint_serving(issued(expires_in)).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + provider.bearer_token().await.expect("a first credential"); + provider.bearer_token().await.expect("a second credential"); + + assert_eq!( + token_requests(&server).await, + 2, + "expires_in {expires_in:?} was cached" + ); + } + } + + /// Many callers starting at once must not each open a token request. The + /// first one performs it and the rest use what it cached. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_callers_make_one_token_request() { + let server = token_endpoint_serving( + issued(Some(TOKEN_LIFETIME_SECONDS)).set_delay(Duration::from_millis(50)), + ) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = Arc::new(provider(endpoint(&server.uri()), &clock)); + + let callers: Vec<_> = (0..20) + .map(|_| { + let provider = provider.clone(); + tokio::spawn(async move { provider.bearer_token().await }) + }) + .collect(); + for caller in callers { + let token = caller + .await + .expect("the caller task ran") + .expect("every caller received a credential"); + assert_eq!(token.expose(), ISSUED_CREDENTIAL); + } + + assert_eq!( + token_requests(&server).await, + 1, + "concurrent callers stampeded the token endpoint" + ); + } + + /// Tokio's asynchronous mutex is not poisoned when a guard is dropped + /// mid-await, unlike `std::sync::Mutex`. A caller abandoned while it holds + /// the refresh lock, whether by cancellation or a panic elsewhere in the + /// same task, must still let the next caller acquire the lock and receive + /// a credential rather than waiting on a lock nothing will ever release. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_dropped_acquisition_releases_the_refresh_lock_for_the_next_caller() { + let server = token_endpoint_serving( + issued(Some(TOKEN_LIFETIME_SECONDS)).set_delay(Duration::from_millis(200)), + ) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = Arc::new(provider(endpoint(&server.uri()), &clock)); + + let abandoned = { + let provider = provider.clone(); + tokio::spawn(async move { provider.bearer_token().await }) + }; + tokio::time::sleep(Duration::from_millis(50)).await; + abandoned.abort(); + let _ = abandoned.await; + + let token = tokio::time::timeout(Duration::from_secs(2), provider.bearer_token()) + .await + .expect("the refresh lock was not left held by the abandoned acquisition") + .expect("a subsequent caller still receives a credential"); + assert_eq!(token.expose(), ISSUED_CREDENTIAL); + // The abandoned task's own request reaching the server proves it was + // past the lock acquisition, and the response delay of four times the + // abort delay proves it was still awaiting that request, so still + // holding the lock, when the abort landed. + assert_eq!( + token_requests(&server).await, + 2, + "the abandoned task never reached the token request it would have held the lock for" + ); + } + + /// A declined request reports the registered code and nothing else. The + /// description is server-authored text about a failed authentication, so it + /// must not reach the caller's diagnostic. + #[tokio::test] + async fn a_declined_token_request_reports_only_the_registered_code() { + let cases = [ + ( + 400, + json!({"error": "invalid_request"}).to_string(), + JSON_MEDIA_TYPE, + TokenError::Refused { + code: OAuthErrorCode::InvalidRequest, + }, + ), + ( + 401, + json!({"error": "invalid_client", "error_description": "canary assertion detail"}) + .to_string(), + JSON_MEDIA_TYPE, + TokenError::Refused { + code: OAuthErrorCode::InvalidClient, + }, + ), + ( + 400, + json!({"error": "canary_extension_code"}).to_string(), + JSON_MEDIA_TYPE, + TokenError::Refused { + code: OAuthErrorCode::Other, + }, + ), + ( + 400, + "canary not json at all".to_owned(), + JSON_MEDIA_TYPE, + TokenError::Protocol { status: 400 }, + ), + ( + 403, + json!({"error": "invalid_client"}).to_string(), + JSON_MEDIA_TYPE, + TokenError::Protocol { status: 403 }, + ), + ( + 500, + json!({"error": "server_error"}).to_string(), + JSON_MEDIA_TYPE, + TokenError::Protocol { status: 500 }, + ), + // A real authorization server states a charset parameter on its + // JSON responses; the essence the gate compares against must + // still match with one present. + ( + 400, + json!({"error": "invalid_request"}).to_string(), + "application/json; charset=utf-8", + TokenError::Refused { + code: OAuthErrorCode::InvalidRequest, + }, + ), + ]; + + for (status, body, media_type, expected) in cases { + let server = token_endpoint_serving( + ResponseTemplate::new(status).set_body_raw(body.clone(), media_type), + ) + .await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + let error = provider + .bearer_token() + .await + .expect_err("the token endpoint declined"); + assert_eq!(error, expected, "status {status}"); + let rendered = error.to_string(); + assert!(!rendered.contains("canary"), "{rendered}"); + } + } + + /// A 400 or 401 body is read as a refusal only when it is announced in the + /// media type the request asked for. An intermediary that answers in a + /// different media type, or none at all, never reached the authorization + /// server's own refusal logic, so it must be reported as a protocol failure + /// rather than as a refusal the adopter cannot act on. + #[tokio::test] + async fn a_declined_status_in_the_wrong_media_type_is_a_protocol_failure() { + let refusal = json!({"error": "invalid_request"}).to_string(); + let cases = [ + ( + 400, + ResponseTemplate::new(400).set_body_bytes(refusal.clone()), + "absent content type", + ), + ( + 401, + ResponseTemplate::new(401).set_body_raw(refusal.clone(), "text/plain"), + "wrong content type", + ), + ]; + + for (status, response, label) in cases { + let server = token_endpoint_serving(response).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + let error = provider + .bearer_token() + .await + .expect_err("the answer is not a usable refusal"); + assert_eq!(error, TokenError::Protocol { status }, "{label}"); + } + } + + /// An answer that is not a usable token response is a protocol failure, never + /// a credential. Each of these would otherwise become a request the deployment + /// refuses for a reason the adopter cannot see. + #[tokio::test] + async fn an_unusable_token_response_is_refused() { + let cases = [ + ( + "a success in the wrong media type", + ResponseTemplate::new(200) + .insert_header("content-type", "text/plain") + .set_body_string( + json!({"access_token": "canary", "token_type": "Bearer"}).to_string(), + ), + ), + ( + "a success carrying no credential", + ResponseTemplate::new(200).set_body_json(json!({"token_type": "Bearer"})), + ), + ( + "a credential the service request cannot present", + ResponseTemplate::new(200).set_body_json( + json!({"access_token": "canary", "token_type": "mac", "expires_in": 300}), + ), + ), + ( + "a credential that is not header safe", + ResponseTemplate::new(200) + .set_body_json(json!({"access_token": "canary token", "token_type": "Bearer"})), + ), + ( + "an unreadable success body", + ResponseTemplate::new(200).set_body_raw("{", "application/json"), + ), + ( + "a redirect instead of an answer", + ResponseTemplate::new(302).insert_header("location", "https://elsewhere.invalid/"), + ), + ]; + + for (description, response) in cases { + let server = token_endpoint_serving(response).await; + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&server.uri()), &clock); + + let error = provider + .bearer_token() + .await + .expect_err("the answer is not a usable token response"); + let rendered = error.to_string(); + assert!(!rendered.contains("canary"), "{description}: {rendered}"); + assert!( + matches!( + error, + TokenError::Protocol { .. } | TokenError::Invalid { .. } + ), + "{description}: {error:?}" + ); + } + } + + /// A token endpoint that never answers is reported as a transport failure, so + /// a caller can tell an unreachable server from a refusal. + #[tokio::test] + async fn a_token_endpoint_that_cannot_be_reached_reports_a_transport_failure() { + // The port is reserved and released, so the connection attempt is refused + // rather than answered. + let reservation = + TcpListener::bind(("127.0.0.1", 0)).expect("a loopback port is available"); + let port = reservation + .local_addr() + .expect("the reservation has an address") + .port(); + drop(reservation); + let clock = Arc::new(TestClock::new(NOW)); + let provider = provider(endpoint(&format!("http://127.0.0.1:{port}")), &clock); + + let error = provider + .bearer_token() + .await + .expect_err("nothing is listening"); + assert_eq!( + error, + TokenError::Transport { + kind: TransportKind::Connect + } + ); + } + + /// A provider that could not authenticate, could not protect its assertion in + /// transit, or could not sign at all fails at construction rather than once + /// per request. + #[test] + fn an_unusable_provider_configuration_is_refused() { + let cases: Vec<(&str, PrivateKeyJwtConfig)> = vec![ + ( + "the client identifier must not be empty", + PrivateKeyJwtConfig::new( + endpoint("https://tokens.example.org"), + " ", + client_key(Some(KEY_ID)), + ), + ), + ( + "the token endpoint must use HTTPS, or HTTP with a loopback host", + config( + endpoint("http://tokens.example.org"), + client_key(Some(KEY_ID)), + ), + ), + ( + "the token endpoint must carry no credentials or fragment", + config( + "https://client:canary@tokens.example.org/token" + .parse() + .expect("the endpoint parses"), + client_key(Some(KEY_ID)), + ), + ), + ( + "the token endpoint must carry no credentials or fragment", + config( + "https://tokens.example.org/token#canary" + .parse() + .expect("the endpoint parses"), + client_key(Some(KEY_ID)), + ), + ), + ( + "the assertion audience must not be empty", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_audience(""), + ), + ( + "the assertion audience must not be empty", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_audience(" "), + ), + ( + "the client key must carry a key identifier", + config(endpoint("https://tokens.example.org"), client_key(None)), + ), + ( + "the client key must carry a key identifier", + config(endpoint("https://tokens.example.org"), { + let mut key = es256_client_key(None); + key.kid = None; + key + }), + ), + ( + "the assertion lifetime must be within 1..=300 seconds", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_assertion_lifetime_seconds(0), + ), + ( + "the assertion lifetime must be within 1..=300 seconds", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_assertion_lifetime_seconds(MAXIMUM_ASSERTION_LIFETIME_SECONDS + 1), + ), + ( + "the refresh margin must not be negative", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_refresh_margin_seconds(-1), + ), + ( + "the timeouts must be greater than zero", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_request_timeout(Duration::ZERO), + ), + ( + "the client key cannot sign a client assertion", + config( + endpoint("https://tokens.example.org"), + unsignable_es256_client_key(), + ), + ), + ( + "the client key cannot sign a client assertion", + config( + endpoint("https://tokens.example.org"), + unsignable_rs256_client_key(), + ), + ), + ( + "the client key's halves belong to different key pairs", + config( + endpoint("https://tokens.example.org"), + mismatched_eddsa_client_key(), + ), + ), + ( + "the client key cannot sign a client assertion", + config( + endpoint("https://tokens.example.org"), + mismatched_es256_client_key(), + ), + ), + ( + "the pinned certificate authority bundle carries no certificate", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_trusted_root_certificates(Vec::new()), + ), + ( + "the pinned certificate authority bundle exceeds the accepted byte bound", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_trusted_root_certificates(vec![ + b'x'; + crate::MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES + + 1 + ]), + ), + ( + "the pinned certificate authority bundle is not readable PEM", + config( + endpoint("https://tokens.example.org"), + client_key(Some(KEY_ID)), + ) + .with_trusted_root_certificates( + b"-----BEGIN CERTIFICATE-----\n!!!!\n-----END CERTIFICATE-----\n".to_vec(), + ), + ), + ]; + + for (reason, candidate) in cases { + let error = PrivateKeyJwt::new(candidate).expect_err(reason); + assert_eq!(error, TokenError::Configuration { reason }); + } + } + + /// A key, a cached credential, and an assertion are all secrets. None of them + /// may reach a rendering. + #[tokio::test] + async fn debug_output_never_carries_the_client_key_or_the_credential() { + let server = token_endpoint_serving(issued(Some(TOKEN_LIFETIME_SECONDS))).await; + let key = client_key(Some(KEY_ID)); + let secret = key + .d + .clone() + .expect("the test key carries private material"); + let candidate = config(endpoint(&server.uri()), key); + let rendered = format!("{candidate:?}"); + assert!(!rendered.contains(&secret), "{rendered}"); + + let clock = Arc::new(TestClock::new(NOW)); + let provider = PrivateKeyJwt::with_clock(candidate, clock.clone()) + .expect("the provider is usable as configured"); + provider.bearer_token().await.expect("a credential"); + let rendered = format!("{provider:?}"); + assert!(!rendered.contains(&secret), "{rendered}"); + assert!(!rendered.contains(ISSUED_CREDENTIAL), "{rendered}"); + assert!(rendered.contains(KEY_ID), "the key identifier is public"); + } + + /// A caller can put userinfo in the token endpoint it names, and a rendering + /// that carried it through would print that credential wherever the + /// configuration is rendered: a wider `Debug`, a panic message, a tracing + /// field. + #[test] + fn debug_output_withholds_userinfo_the_caller_put_in_the_token_endpoint() { + let candidate = config( + Url::parse("https://client:s3cr3t@issuer.example.org/token?secret-query=canary") + .expect("the token endpoint parses"), + client_key(Some(KEY_ID)), + ); + + let rendered = format!("{candidate:?}"); + + assert!(!rendered.contains("s3cr3t"), "{rendered}"); + assert!(!rendered.contains("secret-query"), "{rendered}"); + assert!(!rendered.contains("canary"), "{rendered}"); + // The separator is what makes a userinfo component one, and no other + // field of this rendering carries it, so its absence is what proves none + // was rendered under any spelling. + assert!(!rendered.contains('@'), "{rendered}"); + // The endpoint still has to be recognizable, or the rendering is no use + // for telling one misconfigured deployment from another. + assert!(rendered.contains("issuer.example.org/token"), "{rendered}"); + + let provider = PrivateKeyJwt::new(config( + Url::parse("https://issuer.example.org/token?secret-query=canary") + .expect("the token endpoint parses"), + client_key(Some(KEY_ID)), + )) + .expect("a query-bearing token endpoint remains usable"); + let rendered = format!("{provider:?}"); + assert!(!rendered.contains("secret-query"), "{rendered}"); + assert!(!rendered.contains("canary"), "{rendered}"); + assert!(rendered.contains("issuer.example.org/token"), "{rendered}"); + } +} diff --git a/crates/registry-platform-httputil/src/client/token.rs b/crates/registry-platform-httputil/src/client/token.rs new file mode 100644 index 000000000..271a6bf99 --- /dev/null +++ b/crates/registry-platform-httputil/src/client/token.rs @@ -0,0 +1,395 @@ +//! Bearer credential acquisition for outbound service requests. +//! +//! A token never reaches a log line, an error, a `Debug` rendering, or a +//! snapshot. It is held in a wrapper that wipes its buffer on drop and is +//! exposed only where the outbound request header is built. + +use std::fmt; + +use async_trait::async_trait; +use http::HeaderValue; +use thiserror::Error; +use zeroize::Zeroizing; + +use super::TransportKind; + +/// Longest accepted credential. Access tokens are bounded well below this; the +/// limit keeps a hostile provider from handing over an unbounded header. +const MAXIMUM_TOKEN_BYTES: usize = 8 * 1024; + +/// One bearer credential for one outbound request. +pub struct BearerToken(Zeroizing); + +impl BearerToken { + /// Accept a credential that can be placed in an `Authorization` header + /// without escaping or folding. + /// + /// The rejection carries no part of the value, so an invalid credential + /// cannot reach a diagnostic through the error path. + pub fn new(value: impl Into) -> Result { + let value = Zeroizing::new(value.into()); + if value.is_empty() || value.len() > MAXIMUM_TOKEN_BYTES { + return Err(TokenError::Invalid { + reason: "a bearer credential must be non-empty and within the accepted length", + }); + } + // Visible ASCII only. This is the header-safe subset, so no credential + // can inject a carriage return, a newline, or a byte the header + // encoder would have to escape. + if !value.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(TokenError::Invalid { + reason: "a bearer credential must contain only visible ASCII characters", + }); + } + Ok(Self(value)) + } + + /// Build a sensitive `Authorization: Bearer` value without exposing token text. + #[must_use] + pub fn authorization_header_value(&self) -> HeaderValue { + let mut encoded = Zeroizing::new(String::with_capacity(7 + self.0.len())); + encoded.push_str("Bearer "); + encoded.push_str(&self.0); + let mut value = HeaderValue::from_str(&encoded) + .expect("BearerToken construction guarantees a valid header value"); + value.set_sensitive(true); + value + } + + /// The credential text for provider internals and same-crate tests only. + #[cfg(test)] + #[allow(dead_code)] + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl Clone for BearerToken { + fn clone(&self) -> Self { + Self(Zeroizing::new(self.0.as_str().to_owned())) + } +} + +impl fmt::Debug for BearerToken { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BearerToken") + .finish_non_exhaustive() + } +} + +/// Source of the bearer credential the client presents. +/// +/// Implementations may cache, refresh, or mint a credential. The client calls +/// this once per outbound request and never stores what it returns. +/// +/// This trait is `#[async_trait]`. An integrator implementing it outside this +/// crate needs the `async-trait` dependency themselves; it is not re-exported. +#[async_trait] +pub trait TokenProvider: Send + Sync { + async fn bearer_token(&self) -> Result; +} + +/// A credential the integrator already holds. +/// +/// This is the deployment where an operator, a supervisor, or an outer service +/// supplies the access token. Renewal is that caller's responsibility. +#[derive(Debug, Clone)] +pub struct StaticToken(BearerToken); + +impl StaticToken { + pub fn new(value: impl Into) -> Result { + Ok(Self(BearerToken::new(value)?)) + } +} + +#[async_trait] +impl TokenProvider for StaticToken { + async fn bearer_token(&self) -> Result { + Ok(self.0.clone()) + } +} + +/// Why a credential could not be supplied. +/// +/// Every message is fixed text. A provider must not place a credential, a +/// response body, or a header value in this error. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum TokenError { + #[error("the token provider could not supply a bearer credential")] + Unavailable, + #[error("the bearer credential is not usable: {reason}")] + Invalid { reason: &'static str }, + + /// The provider cannot be used as configured. The reason is fixed text + /// chosen by the provider, never caller data and never key material. + #[error("the token provider cannot be used as configured: {reason}")] + Configuration { reason: &'static str }, + + /// The exchange with the authorization server did not complete. + #[error("the token request did not complete: {kind}")] + Transport { kind: TransportKind }, + + /// The authorization server declined to issue a token. The registered error + /// code is the whole of what is reported. + #[error("the authorization server declined to issue a token: {code}")] + Refused { code: OAuthErrorCode }, + + /// The answer was not a token response this crate can use: an unexpected + /// status, an unexpected media type, an unreadable body, or a token type the + /// service request cannot present. + #[error("the token response does not satisfy the OAuth 2.0 contract: status {status}")] + Protocol { status: u16 }, +} + +impl TokenError { + /// A stable, machine-readable name for which kind of token failure this is. + /// + /// It exists for callers that have to branch or aggregate without matching + /// an enum this crate may extend: a metric label, a structured log field, or + /// a language binding that carries the discriminant across a boundary. The + /// rendered message is for people and may be reworded; these names are part + /// of the crate's contract and will not be renamed. A variant added later + /// brings a new name rather than reusing one of these. + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Unavailable => "unavailable", + Self::Invalid { .. } => "invalid_credential", + Self::Configuration { .. } => "configuration", + Self::Transport { .. } => "transport", + Self::Refused { .. } => "refused", + Self::Protocol { .. } => "protocol", + } + } +} + +/// The OAuth 2.0 error code an authorization server returned. +/// +/// This code is all a refused token request reports. The accompanying +/// `error_description` is server-authored text about a failed authentication +/// attempt, so it is dropped where the body is parsed rather than carried into a +/// diagnostic, and the client assertion and the key that signed it are never part +/// of any of these values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum OAuthErrorCode { + InvalidRequest, + InvalidClient, + InvalidGrant, + UnauthorizedClient, + UnsupportedGrantType, + InvalidScope, + /// A code outside RFC 6749 section 5.2. The server's own spelling is + /// deliberately not kept: it is unbounded text from the failed exchange, and + /// the extension registry is open, so no closed variant could hold it. + Other, +} + +impl OAuthErrorCode { + /// The registered spelling, or a fixed name for a code from outside the + /// section 5.2 set. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::InvalidRequest => "invalid_request", + Self::InvalidClient => "invalid_client", + Self::InvalidGrant => "invalid_grant", + Self::UnauthorizedClient => "unauthorized_client", + Self::UnsupportedGrantType => "unsupported_grant_type", + Self::InvalidScope => "invalid_scope", + Self::Other => "unregistered_error_code", + } + } + + /// Read a code off the wire, keeping only whether it is one this crate names. + pub(crate) fn from_wire(code: &str) -> Self { + match code { + "invalid_request" => Self::InvalidRequest, + "invalid_client" => Self::InvalidClient, + "invalid_grant" => Self::InvalidGrant, + "unauthorized_client" => Self::UnauthorizedClient, + "unsupported_grant_type" => Self::UnsupportedGrantType, + "invalid_scope" => Self::InvalidScope, + _ => Self::Other, + } + } +} + +impl fmt::Display for OAuthErrorCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn a_static_provider_returns_the_configured_credential() { + let provider = StaticToken::new("header-safe-token").expect("the credential is accepted"); + let token = provider + .bearer_token() + .await + .expect("a static provider always succeeds"); + assert_eq!(token.expose(), "header-safe-token"); + let authorization = token.authorization_header_value(); + assert_eq!(authorization.to_str().unwrap(), "Bearer header-safe-token"); + assert!(authorization.is_sensitive()); + } + + #[test] + fn unusable_credentials_are_refused_without_echoing_them() { + for candidate in [ + "", + "canary space", + "canary\ttab", + "canary\rcarriage-return", + "canary\nnewline", + "canary\u{00e9}-non-ascii", + ] { + let error = BearerToken::new(candidate).expect_err("the credential is refused"); + let rendered = error.to_string(); + assert!( + !rendered.contains("canary"), + "the error rendered part of the credential: {rendered}" + ); + } + assert!(BearerToken::new("A".repeat(MAXIMUM_TOKEN_BYTES + 1)).is_err()); + } + + /// A refusal names the registered code and nothing else, and every acquisition + /// failure renders as its own sentence so a support conversation can start + /// from the message alone. + #[test] + fn acquisition_failures_render_their_own_fixed_text() { + let cases = [ + ( + TokenError::Configuration { + reason: "the client identifier must not be empty", + }, + "the token provider cannot be used as configured: the client identifier must not be empty", + ), + ( + TokenError::Transport { + kind: TransportKind::Connect, + }, + "the token request did not complete: connection setup failed", + ), + ( + TokenError::Refused { + code: OAuthErrorCode::InvalidClient, + }, + "the authorization server declined to issue a token: invalid_client", + ), + ( + TokenError::Refused { + code: OAuthErrorCode::Other, + }, + "the authorization server declined to issue a token: unregistered_error_code", + ), + ( + TokenError::Protocol { status: 500 }, + "the token response does not satisfy the OAuth 2.0 contract: status 500", + ), + ]; + for (error, rendered) in &cases { + assert_eq!(&error.to_string(), rendered); + } + let renderings: std::collections::BTreeSet = + cases.iter().map(|(error, _)| error.to_string()).collect(); + assert_eq!( + renderings.len(), + cases.len(), + "two failures render the same text" + ); + } + + /// The discriminant is what a binding, a metric label, or a caller's own + /// branch reads, so every variant has one and no two share it. + #[test] + fn every_token_failure_reports_its_own_stable_kind() { + let cases = [ + (TokenError::Unavailable, "unavailable"), + ( + TokenError::Invalid { + reason: "a bearer credential must be non-empty and within the accepted length", + }, + "invalid_credential", + ), + ( + TokenError::Configuration { + reason: "the client identifier must not be empty", + }, + "configuration", + ), + ( + TokenError::Transport { + kind: TransportKind::Connect, + }, + "transport", + ), + ( + TokenError::Refused { + code: OAuthErrorCode::InvalidClient, + }, + "refused", + ), + (TokenError::Protocol { status: 500 }, "protocol"), + ]; + for (error, kind) in &cases { + assert_eq!(error.kind(), *kind, "{error}"); + } + let kinds: std::collections::BTreeSet<&str> = + cases.iter().map(|(error, _)| error.kind()).collect(); + assert_eq!(kinds.len(), cases.len(), "two variants share a kind"); + } + + /// A server may spell a code however it likes. Only the registered set is + /// named, so an unbounded spelling cannot travel in the error. + #[test] + fn unregistered_error_codes_collapse_to_one_name() { + assert_eq!( + OAuthErrorCode::from_wire("invalid_request"), + OAuthErrorCode::InvalidRequest + ); + assert_eq!( + OAuthErrorCode::from_wire("invalid_client"), + OAuthErrorCode::InvalidClient + ); + assert_eq!( + OAuthErrorCode::from_wire("invalid_grant"), + OAuthErrorCode::InvalidGrant + ); + assert_eq!( + OAuthErrorCode::from_wire("unauthorized_client"), + OAuthErrorCode::UnauthorizedClient + ); + assert_eq!( + OAuthErrorCode::from_wire("unsupported_grant_type"), + OAuthErrorCode::UnsupportedGrantType + ); + assert_eq!( + OAuthErrorCode::from_wire("invalid_scope"), + OAuthErrorCode::InvalidScope + ); + for candidate in ["", "Invalid_Client", "canary_extension_code"] { + let code = OAuthErrorCode::from_wire(candidate); + assert_eq!(code, OAuthErrorCode::Other, "{candidate}"); + assert!(!code.to_string().contains("canary"), "{candidate}"); + } + } + + #[test] + fn debug_output_never_carries_the_credential() { + let token = BearerToken::new("secret-canary-value").expect("the credential is accepted"); + let rendered = format!("{token:?}"); + assert!(!rendered.contains("secret-canary-value"), "{rendered}"); + + let provider = StaticToken::new("secret-canary-value").expect("the credential is accepted"); + let rendered = format!("{provider:?}"); + assert!(!rendered.contains("secret-canary-value"), "{rendered}"); + } +} diff --git a/crates/registry-platform-httputil/src/destination.rs b/crates/registry-platform-httputil/src/destination.rs index b418a35f2..f3ac5c2e0 100644 --- a/crates/registry-platform-httputil/src/destination.rs +++ b/crates/registry-platform-httputil/src/destination.rs @@ -71,13 +71,14 @@ pub const MAX_DESTINATION_REQUEST_BODY_BYTES: usize = 1_048_576; /// Maximum response-body ceiling accepted by the platform transport. pub const MAX_DESTINATION_RESPONSE_BODY_BYTES: usize = 16_777_216; /// Maximum parsed upstream response-header count. -pub const MAX_DESTINATION_RESPONSE_HEADERS: usize = 64; +pub const MAX_DESTINATION_RESPONSE_HEADERS: usize = crate::MAXIMUM_RESPONSE_HEADER_FIELDS; /// Maximum aggregate parsed upstream response-header name and value bytes. -pub const MAX_DESTINATION_RESPONSE_HEADER_BYTES: usize = 65_536; +pub const MAX_DESTINATION_RESPONSE_HEADER_BYTES: usize = crate::MAXIMUM_RESPONSE_HEADER_BYTES; /// Maximum configured private CA bundle bytes retained during TLS activation. -pub const MAX_DESTINATION_CA_BUNDLE_BYTES: usize = 1_048_576; +pub const MAX_DESTINATION_CA_BUNDLE_BYTES: usize = + crate::MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES; /// Maximum certificates accepted from one configured private CA bundle. -pub const MAX_DESTINATION_CA_CERTIFICATES: usize = 32; +pub const MAX_DESTINATION_CA_CERTIFICATES: usize = crate::MAXIMUM_TRUSTED_ROOT_CERTIFICATES; /// Maximum combined client certificate-chain and private-key PEM bytes. pub const MAX_DESTINATION_CLIENT_IDENTITY_BYTES: usize = 524_288; /// Frozen hard maximum for DNS, connect, send, and response body read together. @@ -3636,20 +3637,15 @@ fn is_valid_header_value(value: &[u8]) -> bool { } fn validate_response_headers(headers: &HeaderMap) -> Result<(), DestinationSendError> { - if headers.len() > MAX_DESTINATION_RESPONSE_HEADERS { - return Err(DestinationSendError::TooManyResponseHeaders); - } - let mut bytes = 0_usize; - for (name, value) in headers { - bytes = bytes - .checked_add(name.as_str().len()) - .and_then(|total| total.checked_add(value.as_bytes().len())) - .ok_or(DestinationSendError::ResponseHeaderBytesExceeded)?; - if bytes > MAX_DESTINATION_RESPONSE_HEADER_BYTES { - return Err(DestinationSendError::ResponseHeaderBytesExceeded); + match crate::validate_response_headers(headers) { + Ok(()) => Ok(()), + Err(crate::ResponseHeaderBoundError::TooManyFields) => { + Err(DestinationSendError::TooManyResponseHeaders) + } + Err(crate::ResponseHeaderBoundError::HeadersTooLarge) => { + Err(DestinationSendError::ResponseHeaderBytesExceeded) } } - Ok(()) } fn origin_explicitly_denotes_loopback(origin: &Url) -> bool { diff --git a/crates/registry-platform-httputil/src/lib.rs b/crates/registry-platform-httputil/src/lib.rs index 9ab0d5f30..420c623c6 100644 --- a/crates/registry-platform-httputil/src/lib.rs +++ b/crates/registry-platform-httputil/src/lib.rs @@ -7,19 +7,108 @@ use http::header::{HeaderName, AUTHORIZATION, CONNECTION, COOKIE, HOST}; use http::HeaderMap; use thiserror::Error; +pub mod client; pub mod destination; +pub use client::{ + BearerToken, OAuthErrorCode, PrivateKeyJwt, PrivateKeyJwtConfig, ServiceBaseUrl, + ServiceBaseUrlError, ServiceBaseUrlJoinError, StaticToken, TokenError, TokenProvider, + TransportKind, DEFAULT_ASSERTION_LIFETIME_SECONDS, DEFAULT_REFRESH_MARGIN_SECONDS, + MAXIMUM_ASSERTION_LIFETIME_SECONDS, MAXIMUM_CACHED_TOKEN_LIFETIME_SECONDS, +}; + +/// Maximum number of response header field lines accepted by shared transports. +pub const MAXIMUM_RESPONSE_HEADER_FIELDS: usize = 64; +/// Maximum bytes accepted across all response header names and values. +pub const MAXIMUM_RESPONSE_HEADER_BYTES: usize = 64 * 1024; +/// Maximum bytes accepted in one outbound client's pinned CA bundle. +pub const MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES: usize = 1024 * 1024; +/// Maximum certificates accepted in one outbound client's pinned CA bundle. +pub const MAXIMUM_TRUSTED_ROOT_CERTIFICATES: usize = 32; + +/// Value-free reason a response's headers exceeded the shared client bounds. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ResponseHeaderBoundError { + #[error("the response carries too many header fields")] + TooManyFields, + #[error("the response headers exceed the configured maximum")] + HeadersTooLarge, +} + +/// Enforce strict field-count and aggregate-byte bounds before inspecting response headers. +pub fn validate_response_headers(headers: &HeaderMap) -> Result<(), ResponseHeaderBoundError> { + let mut count = 0usize; + let mut total = 0usize; + for (name, value) in headers { + count = count + .checked_add(1) + .ok_or(ResponseHeaderBoundError::TooManyFields)?; + if count > MAXIMUM_RESPONSE_HEADER_FIELDS { + return Err(ResponseHeaderBoundError::TooManyFields); + } + let field_bytes = name + .as_str() + .len() + .checked_add(value.as_bytes().len()) + .ok_or(ResponseHeaderBoundError::HeadersTooLarge)?; + total = total + .checked_add(field_bytes) + .ok_or(ResponseHeaderBoundError::HeadersTooLarge)?; + if total > MAXIMUM_RESPONSE_HEADER_BYTES { + return Err(ResponseHeaderBoundError::HeadersTooLarge); + } + } + Ok(()) +} + +/// Parse exactly one RFC 9110 delta-seconds `Retry-After` field within a caller bound. +/// +/// HTTP-date values, duplicate field lines, non-ASCII values, zero, and waits above +/// `maximum_seconds` are deliberately not actionable. +#[must_use] +pub fn retry_after_seconds(headers: &HeaderMap, maximum_seconds: u64) -> Option { + let mut values = headers.get_all(http::header::RETRY_AFTER).iter(); + let value = match (values.next(), values.next()) { + (Some(value), None) => value.to_str().ok()?, + _ => return None, + }; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + value + .parse::() + .ok() + .filter(|seconds| (1..=maximum_seconds).contains(seconds)) +} + /// Default timeout for requests built from [`ValidatedFetchUrl`]. pub const DEFAULT_VALIDATED_FETCH_TIMEOUT: Duration = Duration::from_secs(30); pub const DEFAULT_VALIDATED_FETCH_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); pub const DEFAULT_OUTBOUND_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Builder for outbound HTTP clients used by platform fetchers. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct OutboundClientBuilder { timeout: Duration, connect_timeout: Duration, user_agent: Option, + trusted_root_certificates: Option>>, +} + +impl std::fmt::Debug for OutboundClientBuilder { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OutboundClientBuilder") + .field("timeout", &self.timeout) + .field("connect_timeout", &self.connect_timeout) + .field("user_agent", &self.user_agent) + .field( + "has_trusted_root_certificates", + &self.trusted_root_certificates.is_some(), + ) + .finish() + } } impl Default for OutboundClientBuilder { @@ -37,6 +126,7 @@ impl OutboundClientBuilder { timeout: Duration::from_secs(30), connect_timeout: DEFAULT_OUTBOUND_CONNECT_TIMEOUT, user_agent: None, + trusted_root_certificates: None, } } @@ -61,26 +151,96 @@ impl OutboundClientBuilder { self } - /// Build a reqwest client. - /// - /// The spec exposes an infallible return type. With the limited options - /// above, construction failures indicate a programming error. + /// Trust exactly this PEM certificate-authority bundle instead of platform roots. #[must_use] - pub fn build(self) -> reqwest::Client { + pub fn trusted_root_certificates(mut self, pem_bundle: impl Into>) -> Self { + self.trusted_root_certificates = Some(zeroize::Zeroizing::new(pem_bundle.into())); + self + } + + /// Fallibly build one hardened client with rustls, redirects and retries disabled, + /// proxy environment variables ignored, and optional exact CA pinning. + pub fn try_build(self) -> Result { let mut builder = reqwest::Client::builder() .timeout(self.timeout) .connect_timeout(self.connect_timeout) .redirect(reqwest::redirect::Policy::none()) - .no_proxy(); + .no_proxy() + .use_rustls_tls() + .retry(reqwest::retry::never()); if let Some(user_agent) = self.user_agent { builder = builder.user_agent(user_agent); } + if let Some(pem) = self.trusted_root_certificates { + if pem.len() > MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES { + return Err(OutboundClientBuildError::CertificateBundleTooLarge); + } + let certificates = reqwest::Certificate::from_pem_bundle(&pem) + .map_err(|_| OutboundClientBuildError::InvalidCertificateBundle)?; + if certificates.is_empty() { + return Err(OutboundClientBuildError::EmptyCertificateBundle); + } + if certificates.len() > MAXIMUM_TRUSTED_ROOT_CERTIFICATES { + return Err(OutboundClientBuildError::TooManyCertificates); + } + for certificate in certificates { + builder = builder.add_root_certificate(certificate); + } + builder = builder.tls_built_in_root_certs(false); + } builder .build() + .map_err(|_| OutboundClientBuildError::InvalidOptions) + } + + /// Build a reqwest client. + /// + /// The spec exposes an infallible return type. With the limited options + /// above, construction failures indicate a programming error. + #[must_use] + pub fn build(self) -> reqwest::Client { + self.try_build() .expect("registry platform outbound client options are valid") } } +/// Fixed, value-free failure building a hardened outbound client. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum OutboundClientBuildError { + #[error("the pinned certificate authority bundle exceeds the accepted byte bound")] + CertificateBundleTooLarge, + #[error("the pinned certificate authority bundle is not readable PEM")] + InvalidCertificateBundle, + #[error("the pinned certificate authority bundle carries no certificate")] + EmptyCertificateBundle, + #[error("the pinned certificate authority bundle carries too many certificates")] + TooManyCertificates, + #[error("the outbound client options are not usable")] + InvalidOptions, +} + +impl OutboundClientBuildError { + #[must_use] + pub fn reason(self) -> &'static str { + match self { + Self::CertificateBundleTooLarge => { + "the pinned certificate authority bundle exceeds the accepted byte bound" + } + Self::InvalidCertificateBundle => { + "the pinned certificate authority bundle is not readable PEM" + } + Self::EmptyCertificateBundle => { + "the pinned certificate authority bundle carries no certificate" + } + Self::TooManyCertificates => { + "the pinned certificate authority bundle carries too many certificates" + } + Self::InvalidOptions => "the outbound client options are not usable", + } + } +} + /// Errors returned by [`read_bounded`]. #[derive(Debug, Error)] #[non_exhaustive] @@ -853,6 +1013,50 @@ mod tests { assert_eq!(response.status(), StatusCode::FOUND); } + #[test] + fn outbound_client_rejects_oversized_ca_bundle_before_parsing() { + let marker = b"canary-certificate-material"; + let mut bundle = vec![b'x'; MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES + 1]; + bundle[..marker.len()].copy_from_slice(marker); + let error = OutboundClientBuilder::new() + .trusted_root_certificates(bundle) + .try_build() + .expect_err("an oversized pinned CA bundle is refused"); + assert_eq!(error, OutboundClientBuildError::CertificateBundleTooLarge); + assert!(!error.to_string().contains("canary")); + assert!(!format!("{error:?}").contains("canary")); + } + + #[test] + fn outbound_client_rejects_too_many_ca_certificates() { + use base64::engine::general_purpose::STANDARD; + use base64::Engine as _; + use rcgen::{generate_simple_self_signed, CertifiedKey}; + + let CertifiedKey { cert, .. } = + generate_simple_self_signed(vec!["registry.example.test".to_owned()]) + .expect("generate TLS fixture"); + let encoded = STANDARD.encode(cert.der().as_ref()); + let body = encoded + .as_bytes() + .chunks(64) + .map(|line| std::str::from_utf8(line).expect("base64 is UTF-8")) + .collect::>() + .join("\n"); + let certificate = + format!("-----BEGIN CERTIFICATE-----\n{body}\n-----END CERTIFICATE-----\n"); + let bundle = certificate.repeat(MAXIMUM_TRUSTED_ROOT_CERTIFICATES + 1); + assert!(bundle.len() < MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES); + + let error = OutboundClientBuilder::new() + .trusted_root_certificates(bundle) + .try_build() + .expect_err("an over-count pinned CA bundle is refused"); + assert_eq!(error, OutboundClientBuildError::TooManyCertificates); + assert!(!error.to_string().contains("BEGIN CERTIFICATE")); + assert!(!format!("{error:?}").contains("BEGIN CERTIFICATE")); + } + #[tokio::test] async fn read_bounded_accepts_body_within_limit() { let base = serve(Router::new().route("/body", get(|| async { "hello" }))).await; @@ -1439,6 +1643,42 @@ mod tests { ); } + #[test] + fn retry_after_requires_one_bounded_delta_seconds_field() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::RETRY_AFTER, "60".parse().unwrap()); + assert_eq!(retry_after_seconds(&headers, 60), Some(60)); + headers.append(http::header::RETRY_AFTER, "1".parse().unwrap()); + assert_eq!(retry_after_seconds(&headers, 60), None); + + for value in ["0", "61", " 1", "+1", "Wed, 21 Oct 2015 07:28:00 GMT"] { + let mut headers = HeaderMap::new(); + headers.insert(http::header::RETRY_AFTER, value.parse().unwrap()); + assert_eq!(retry_after_seconds(&headers, 60), None, "{value}"); + } + } + + #[test] + fn response_header_bounds_are_value_free_and_cover_count_and_size() { + let mut too_many = HeaderMap::new(); + for index in 0..=MAXIMUM_RESPONSE_HEADER_FIELDS { + too_many.append("x-test", HeaderValue::from_str(&index.to_string()).unwrap()); + } + assert_eq!( + validate_response_headers(&too_many), + Err(ResponseHeaderBoundError::TooManyFields) + ); + + let mut too_large = HeaderMap::new(); + too_large.insert( + "x-canary", + HeaderValue::from_bytes(&vec![b'a'; MAXIMUM_RESPONSE_HEADER_BYTES]).unwrap(), + ); + let error = validate_response_headers(&too_large).unwrap_err(); + assert_eq!(error, ResponseHeaderBoundError::HeadersTooLarge); + assert!(!error.to_string().contains("canary")); + } + proptest! { #[test] fn append_path_segments_keeps_each_input_as_one_segment( diff --git a/crates/registry-platform-testing/Cargo.toml b/crates/registry-platform-testing/Cargo.toml index 7afd170d2..f8e5b02ca 100644 --- a/crates/registry-platform-testing/Cargo.toml +++ b/crates/registry-platform-testing/Cargo.toml @@ -22,7 +22,7 @@ bytes.workspace = true jsonwebtoken.workspace = true registry-platform-audit = { workspace = true } registry-platform-crypto = { workspace = true } -registry-platform-httpsec = { workspace = true } +registry-platform-httpsec = { workspace = true, features = ["server"] } registry-platform-httputil = { workspace = true } registry-platform-oidc = { workspace = true } reqwest.workspace = true diff --git a/crates/registry-relay-client-node/.gitignore b/crates/registry-relay-client-node/.gitignore new file mode 100644 index 000000000..79946157d --- /dev/null +++ b/crates/registry-relay-client-node/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.node +index.d.ts.check diff --git a/crates/registry-relay-client-node/Cargo.toml b/crates/registry-relay-client-node/Cargo.toml new file mode 100644 index 000000000..f62c62856 --- /dev/null +++ b/crates/registry-relay-client-node/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "registry-relay-client-node" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Node.js binding for the Registry Relay V2 client, via napi-rs." +readme = "README.md" +repository.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +napi.workspace = true +napi-derive.workspace = true +registry-platform-crypto.workspace = true +registry-relay-client.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +url.workspace = true + +[build-dependencies] +napi-build.workspace = true + +[dev-dependencies] +axum.workspace = true +tokio.workspace = true diff --git a/crates/registry-relay-client-node/LICENSE b/crates/registry-relay-client-node/LICENSE new file mode 100644 index 000000000..0421f3c2d --- /dev/null +++ b/crates/registry-relay-client-node/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Jeremi Joslin + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/registry-relay-client-node/README.md b/crates/registry-relay-client-node/README.md new file mode 100644 index 000000000..7d030305e --- /dev/null +++ b/crates/registry-relay-client-node/README.md @@ -0,0 +1,54 @@ +# registry-relay-client-node + +Thin napi-rs binding for the Rust `registry-relay-client` SDK. It exposes no +JavaScript HTTP, routing, authentication, retry, or problem parsing logic. +All Relay wire handling remains in the wrapped Rust client. + +```js +const { RelayClient } = require('@registrystack/relay-client'); +const client = new RelayClient({ baseUrl: 'https://relay.example.invalid/' }); +const health = await client.health(); +``` + +Pass request choices as plain objects. Paginated collection responses return a +validated continuation object that can be handed only to the matching method: + +```js +const first = await client.listRecords('people', { + pageSize: 25, + fields: ['name'], + format: 'json', +}); +const second = first.kind === 'complete' && first.continuation + ? await client.continueListRecords(first.continuation) + : null; +``` + +Resource discovery similarly returns a closed `{ cursor }` continuation object +for `continueResources`; raw cursor strings are not accepted. + +Before native conversion, every call clones its inputs from an acyclic plain +JSON graph. Objects must have `Object.prototype` or a null prototype and must +contain only own enumerable string data properties; proxies, accessors, symbol +members, exotic prototypes, sparse arrays, and non-JSON values are rejected. +One aggregate budget per call permits at most 128 levels, 100,000 values, and +4 MiB of UTF-8 string data. These checks prevent recursive native conversion +of cyclic or active JavaScript objects. + +Raw OpenAPI, artifact, and SDMX responses carry `body` as a Node `Buffer` and +`mediaType` as the accepted server media type. + +Authentication is optional. Configure either one static bearer token or one +private-key-JWT provider. The latter accepts `tokenEndpoint`, `clientId`, and a +private `clientKey` JWK, plus the timeout, audience, refresh, user-agent, and +CA-pinning settings declared in `client.d.ts`. Its token request contains only +`grant_type`, `client_assertion_type`, and `client_assertion`. It deliberately +has no scope, RFC 8707 resource, or body `client_id` option. If an issuer needs +one of those fields, obtain a short-lived bearer token separately and pass it +as `{ authorization: { static: token } }`; Rust callers can instead implement a +custom `TokenProvider`. + +Every mapped failure is thrown as `RelayClientError` with a stable `kind` and, +when the Rust error provides them, `code`, `status`, `traceId`, +`retryAfterSeconds`, `transportKind`, and `tokenKind`. No error exposes token +or private-key material. diff --git a/crates/registry-relay-client-node/__test__/binding.test.js b/crates/registry-relay-client-node/__test__/binding.test.js new file mode 100644 index 000000000..b44251661 --- /dev/null +++ b/crates/registry-relay-client-node/__test__/binding.test.js @@ -0,0 +1,331 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const http = require('node:http'); +const path = require('node:path'); +const { after, before, test } = require('node:test'); + +const { RelayClient, RelayClientError } = require('..'); + +const TRACE_ID = '4bf92f3577b34da6a3ce929d0e0e4736'; +const TRACEPARENT = `00-${TRACE_ID}-00f067aa0ba902b7-01`; +const ETAG = `"${'0123456789abcdef'.repeat(4)}"`; + +function assertBoundaryChildExitsNormally(source) { + const result = spawnSync(process.execPath, ['-e', source], { + cwd: path.join(__dirname, '..'), + encoding: 'utf8', + timeout: 10_000, + }); + assert.equal(result.error, undefined); + assert.equal(result.signal, null, `child terminated by ${result.signal}: ${result.stderr}`); + assert.equal(result.status, 0, result.stderr); +} + +let server; +let baseUrl; + +before(async () => { + server = http.createServer((request, response) => { + response.setHeader('traceparent', TRACEPARENT); + response.setHeader('content-type', 'application/json'); + if (request.headers['if-none-match'] === ETAG) { + response.statusCode = 304; + response.setHeader('etag', ETAG); + response.end(); + return; + } + if (request.url.endsWith('/health')) { + response.end(JSON.stringify({ status: 'ok' })); + return; + } + if (request.url.endsWith('/ready')) { + response.end(JSON.stringify({ status: 'ready' })); + return; + } + if (request.url.endsWith('/openapi.json')) { + response.end('{"openapi":"3.1.0"}'); + return; + } + response.statusCode = 404; + response.end('{}'); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}/tenant`; +}); + +after(async () => { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +}); + +test('probe results are plain camelCase object graphs', async () => { + const result = await new RelayClient({ baseUrl }).health(); + assert.equal(Object.getPrototypeOf(result), Object.prototype); + assert.equal(result.kind, 'complete'); + assert.deepEqual(result.value, { status: 'ok' }); + assert.equal(result.traceId, TRACE_ID); +}); + +test('raw document bodies cross as Buffer values', async () => { + const result = await new RelayClient({ baseUrl }).openapi(); + assert.equal(result.kind, 'complete'); + assert.ok(Buffer.isBuffer(result.body)); + assert.equal(result.body.toString('utf8'), '{"openapi":"3.1.0"}'); + assert.equal(result.mediaType, 'application/json'); +}); + +test('every endpoint accepts its documented plain input graph', async () => { + const client = new RelayClient({ baseUrl }); + const expectNotModified = async (promise) => { + const result = await promise; + assert.deepEqual(result, { kind: 'notModified', etag: ETAG, traceId: TRACE_ID }); + }; + + assert.equal((await client.health()).value.status, 'ok'); + assert.equal((await client.ready()).value.status, 'ready'); + await expectNotModified(client.openapi(ETAG)); + await expectNotModified(client.serviceMetadata(ETAG)); + await expectNotModified(client.resources({ pageSize: 10 }, ETAG)); + await expectNotModified(client.continueResources({ cursor: 'resource-cursor' }, ETAG)); + await expectNotModified(client.resource('people', ETAG)); + await expectNotModified(client.listRecords('people', { + pageSize: 10, + fields: ['name'], + accessProfile: 'public', + format: 'geojson', + filters: { status: 'active' }, + bbox: [-10, -5, 10, 5], + }, ETAG)); + await expectNotModified(client.continueListRecords({ + route: { kind: 'records', resource: 'people' }, + cursor: 'records-cursor', + format: 'json', + accessProfile: 'public', + }, ETAG)); + await expectNotModified(client.readRecord('people', 'person-1', { + fields: ['name'], + accessProfile: 'public', + format: 'json-ld', + }, ETAG)); + await expectNotModified(client.lookup('people', 'by-identity', { + number: 42, + active: true, + jurisdiction: 'AA', + }, { format: 'json' }, ETAG)); + await expectNotModified(client.search('people', 'by-name', { + pageSize: 10, + filters: { status: 'active' }, + }, ETAG)); + await expectNotModified(client.continueSearch({ + route: { kind: 'search', resource: 'people', search: 'by-name' }, + cursor: 'search-cursor', + format: 'json-fg', + }, ETAG)); + await expectNotModified(client.artifact('schema', ETAG)); + await expectNotModified(client.sdmxData({ + agency: 'AGENCY', + resource: 'FLOW', + version: '1.0.0', + key: 'A.B', + constraints: { TIME_PERIOD: 'ge:2020+le:2024' }, + offset: 1, + limit: 10, + dimensionAtObservation: 'AllDimensions', + format: 'csv', + }, ETAG)); + await expectNotModified(client.sdmxStructure({ + kind: 'dataflow', + agency: 'AGENCY', + resource: 'FLOW', + version: '1.0.0', + }, ETAG)); +}); + +test('request validation failures have a distinct stable kind', async () => { + const client = new RelayClient({ baseUrl }); + await assert.rejects( + client.resources({ pageSize: 0 }), + (error) => error instanceof RelayClientError && error.kind === 'invalid_request', + ); +}); + +test('synchronous napi argument conversion failures use fixed redacted envelopes', () => { + const client = new RelayClient({ baseUrl }); + for (const invoke of [ + () => client.resource(42), + () => client.resources({ pageSize: 1n }), + () => client.lookup('people', 'by-identity', undefined), + () => client.lookup('people', 'by-identity', { number: Number.NaN }), + () => client.lookup('people', 'by-identity', { number: Number.POSITIVE_INFINITY }), + ]) { + assert.throws( + invoke, + (error) => error instanceof RelayClientError + && error.kind === 'invalid_request' + && error.message === 'Relay client arguments are invalid', + ); + } +}); + +test('constructor napi conversion failures use fixed configuration envelopes', () => { + for (const config of [{ baseUrl: 1n }, undefined]) { + assert.throws( + () => new RelayClient(config), + (error) => error instanceof RelayClientError + && error.kind === 'configuration' + && error.message === 'Relay client configuration is invalid', + ); + } +}); + +test('cyclic inputs are rejected without aborting the Node process', () => { + const prelude = ` + const assert = require('node:assert/strict'); + const { RelayClient, RelayClientError } = require('.'); + const matches = (kind) => (error) => error instanceof RelayClientError + && error.kind === kind + && error.message === (kind === 'configuration' + ? 'Relay client configuration is invalid' + : 'Relay client arguments are invalid'); + `; + assertBoundaryChildExitsNormally(`${prelude} + const config = { baseUrl: 'http://127.0.0.1:1' }; + config.self = config; + assert.throws(() => new RelayClient(config), matches('configuration')); + `); + assertBoundaryChildExitsNormally(`${prelude} + const client = new RelayClient({ baseUrl: 'http://127.0.0.1:1' }); + const selectors = {}; + selectors.self = selectors; + assert.throws( + () => client.lookup('people', 'by-identity', selectors), + matches('invalid_request'), + ); + `); + assertBoundaryChildExitsNormally(`${prelude} + const client = new RelayClient({ baseUrl: 'http://127.0.0.1:1' }); + const options = {}; + options.self = options; + assert.throws( + () => client.lookup('people', 'by-identity', {}, options), + matches('invalid_request'), + ); + `); +}); + +test('plain JSON cloning rejects active and exotic JavaScript behavior', () => { + const client = new RelayClient({ baseUrl }); + const matchesInvalidRequest = (error) => error instanceof RelayClientError + && error.kind === 'invalid_request' + && error.message === 'Relay client arguments are invalid'; + + let getterInvoked = false; + const accessor = {}; + Object.defineProperty(accessor, 'number', { + enumerable: true, + get() { + getterInvoked = true; + return 42; + }, + }); + assert.throws(() => client.lookup('people', 'by-identity', accessor), matchesInvalidRequest); + assert.equal(getterInvoked, false); + + let proxyTrapInvoked = false; + const proxy = new Proxy({}, { + ownKeys() { + proxyTrapInvoked = true; + return []; + }, + }); + assert.throws(() => client.lookup('people', 'by-identity', proxy), matchesInvalidRequest); + assert.equal(proxyTrapInvoked, false); + + const symbolMember = {}; + symbolMember[Symbol('hidden')] = 'value'; + assert.throws(() => client.lookup('people', 'by-identity', symbolMember), matchesInvalidRequest); + assert.throws( + () => client.lookup('people', 'by-identity', Object.create({ number: 42 })), + matchesInvalidRequest, + ); +}); + +test('plain JSON cloning enforces one bounded budget across all arguments', () => { + const client = new RelayClient({ baseUrl }); + const matchesInvalidRequest = (error) => error instanceof RelayClientError + && error.kind === 'invalid_request' + && error.message === 'Relay client arguments are invalid'; + + const halfBudget = 'x'.repeat(2_100_000); + assert.throws( + () => client.lookup('people', 'by-identity', { one: halfBudget }, { fields: [halfBudget] }), + matchesInvalidRequest, + ); + assert.throws( + () => client.readRecord('people', 'one', { fields: new Array(100_000).fill('x') }), + matchesInvalidRequest, + ); + + const deep = {}; + let cursor = deep; + for (let index = 0; index < 129; index += 1) { + cursor.next = {}; + cursor = cursor.next; + } + assert.throws(() => client.lookup('people', 'by-identity', deep), matchesInvalidRequest); +}); + +test('continuations cannot cross list and search methods', async () => { + const client = new RelayClient({ baseUrl }); + await assert.rejects( + client.continueListRecords({ + route: { kind: 'search', resource: 'people', search: 'by-name' }, + cursor: 'opaque', + format: 'json', + }), + (error) => error instanceof RelayClientError && error.kind === 'invalid_request', + ); +}); + +test('configuration failures never repeat static credentials', () => { + const secret = 'canary-static-token'; + assert.throws( + () => new RelayClient({ + baseUrl: 'https://relay.invalid', + authorization: { static: secret, privateKeyJwt: {} }, + }), + (error) => error instanceof RelayClientError + && error.kind === 'configuration' + && !error.message.includes(secret), + ); +}); + +test('private-key JWT configuration is accepted without token-endpoint I/O', () => { + const { privateKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const clientKey = privateKey.export({ format: 'jwk' }); + clientKey.alg = 'ES256'; + clientKey.kid = 'node-binding-test-key'; + const client = new RelayClient({ + baseUrl, + authorization: { + privateKeyJwt: { + tokenEndpoint: 'https://issuer.invalid/oauth/token', + clientId: 'node-binding-test-client', + clientKey, + audience: 'https://issuer.invalid/oauth/token', + assertionLifetimeSeconds: 60, + refreshMarginSeconds: 10, + requestTimeoutMilliseconds: 1_000, + connectTimeoutMilliseconds: 500, + userAgent: 'registry-relay-client-node-test', + }, + }, + }); + assert.ok(client); +}); diff --git a/crates/registry-relay-client-node/__test__/drift.test.js b/crates/registry-relay-client-node/__test__/drift.test.js new file mode 100644 index 000000000..12e9abff3 --- /dev/null +++ b/crates/registry-relay-client-node/__test__/drift.test.js @@ -0,0 +1,58 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const wrapper = require('..'); +const native = require('../index.js'); + +const METHODS = [ + 'health', + 'ready', + 'openapi', + 'serviceMetadata', + 'resources', + 'continueResources', + 'resource', + 'listRecords', + 'continueListRecords', + 'readRecord', + 'lookup', + 'search', + 'continueSearch', + 'artifact', + 'sdmxData', + 'sdmxStructure', +]; + +test('the error wrapper accounts for every native client method', () => { + const actual = Object.getOwnPropertyNames(native.RelayClient.prototype) + .filter((name) => name !== 'constructor') + .filter((name) => typeof Object.getOwnPropertyDescriptor(native.RelayClient.prototype, name).value === 'function') + .sort(); + assert.deepEqual(actual, [...METHODS].sort()); +}); + +test('the handwritten facade declares every method', () => { + const declaration = fs.readFileSync(path.join(__dirname, '..', 'client.d.ts'), 'utf8'); + for (const name of METHODS) { + assert.match(declaration, new RegExp(`\\b${name}\\(`)); + } +}); + +test('only the normalized package entry point is exported', () => { + assert.equal(require('@registrystack/relay-client').RelayClient, wrapper.RelayClient); + assert.throws( + () => require('@registrystack/relay-client/index.js'), + (error) => error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED', + ); +}); + +test('the package carries its declared Apache license', () => { + const packageJson = require('../package.json'); + assert.equal(packageJson.license, 'Apache-2.0'); + const license = fs.readFileSync(path.join(__dirname, '..', 'LICENSE'), 'utf8'); + assert.match(license, /Apache License/); +}); diff --git a/crates/registry-relay-client-node/build.rs b/crates/registry-relay-client-node/build.rs new file mode 100644 index 000000000..0f1b01002 --- /dev/null +++ b/crates/registry-relay-client-node/build.rs @@ -0,0 +1,3 @@ +fn main() { + napi_build::setup(); +} diff --git a/crates/registry-relay-client-node/client.d.ts b/crates/registry-relay-client-node/client.d.ts new file mode 100644 index 000000000..780f9f376 --- /dev/null +++ b/crates/registry-relay-client-node/client.d.ts @@ -0,0 +1,246 @@ +export type JsonScalar = string | number | boolean | null +export type JsonValue = JsonScalar | ReadonlyArray | { readonly [key: string]: JsonValue } + +export interface PrivateJwk { + readonly kty: string + readonly kid: string + readonly alg: string + readonly [member: string]: JsonValue +} + +export interface PrivateKeyJwtConfig { + tokenEndpoint: string + clientId: string + clientKey: PrivateJwk + audience?: string | null + assertionLifetimeSeconds?: number | null + refreshMarginSeconds?: number | null + requestTimeoutMilliseconds?: number | null + connectTimeoutMilliseconds?: number | null + userAgent?: string | null + trustedRootCertificates?: string | null +} + +export type RelayAuthorization = + | { static: string } + | { privateKeyJwt: PrivateKeyJwtConfig } + +export interface RelayClientConfig { + baseUrl: string + authorization?: RelayAuthorization | null + requestTimeoutMilliseconds?: number | null + connectTimeoutMilliseconds?: number | null + userAgent?: string | null + maxResponseBytes?: number | null + trustedRootCertificates?: string | null +} + +export type RecordFormat = 'json' | 'json-ld' | 'geojson' | 'geo-json-rfc7946' | 'json-fg' + +export interface ResourceListOptions { + pageSize?: number | null +} + +export interface ResourceContinuation { + cursor: string +} + +export interface RecordOptions { + fields?: ReadonlyArray | null + accessProfile?: string | null + format?: RecordFormat | null +} + +export interface CollectionOptions extends RecordOptions { + pageSize?: number | null + filters?: Readonly> | null + /** `[west, south, east, north]` in WGS84 longitude/latitude degrees. */ + bbox?: readonly [number, number, number, number] | null +} + +export type LookupSelector = string | number | boolean +export type LookupSelectors = Readonly> + +export interface RecordsRoute { + kind: 'records' + resource: string +} + +export interface SearchRoute { + kind: 'search' + resource: string + search: string +} + +export interface CollectionContinuation { + route: Route + cursor: string + format: 'json' | 'json-ld' | 'geo-json-rfc7946' | 'json-fg' + accessProfile?: string +} + +export interface SdmxDataRequest { + agency: string + resource: string + /** A three-part `x.y.z` SDMX version. */ + version: string + key?: string | null + constraints?: Readonly> | null + offset?: number | null + limit?: number | null + dimensionAtObservation?: string | null + format?: 'json' | 'csv' | null +} + +export interface SdmxStructureRequest { + kind: 'dataflow' | 'datastructure' | 'data-structure' + agency: string + resource: string + /** A three-part `x.y.z` SDMX version. */ + version: string +} + +export interface ProbeStatus { + status: string +} + +export interface Institution { + identifier: string + name: string +} + +export interface ServiceMetadata { + registryIdentifier: string + name: string + authority: Institution + operator: Institution | null + authoritativeScope: string + product: { name: string; version: string } + apiBinding: { name: string; version: string } + alignmentTargets: ReadonlyArray + capabilities: ReadonlyArray + links: { self: string; resources: string; openapi: string } +} + +export interface ResourceDocument { + resourceIdentifier: string + title: string + description: string + semanticClass: string + enumerationPosture: string + capabilities: ReadonlyArray + links: { self: string } +} + +export interface ResourceCollection { + items: ReadonlyArray + pageInfo: { nextCursor: string | null } + meta: { registryIdentifier: string } +} + +export interface ResourceEnvelope { + data: ResourceDocument + meta: { registryIdentifier: string } +} + +export interface RegistryRecord { + registryIdentifier: string + recordIdentifier: string + revisionIdentifier: string + lifecycleState: string + schemaReference: string + semanticModelReference: string + authorityIdentifier: string + recordedAt: string + domainData: Readonly> + '@id'?: string + '@type'?: string +} + +export type RecordResponse = JsonValue +export type RecordCollectionResponse = JsonValue + +export interface CompleteOutcome { + kind: 'complete' + value: T + traceId: string + etag?: string +} + +export interface NotModifiedOutcome { + kind: 'notModified' + etag: string + traceId: string +} + +export type Outcome = CompleteOutcome | NotModifiedOutcome + +export interface ResourcePageCompleteOutcome { + kind: 'complete' + value: ResourceCollection + continuation?: ResourceContinuation + traceId: string + etag?: string +} + +export type ResourcePageOutcome = ResourcePageCompleteOutcome | NotModifiedOutcome + +export interface CollectionPageCompleteOutcome { + kind: 'complete' + value: RecordCollectionResponse + continuation?: CollectionContinuation + traceId: string + etag?: string +} + +export type CollectionPageOutcome = CollectionPageCompleteOutcome | NotModifiedOutcome + +export interface RawCompleteOutcome { + kind: 'complete' + body: Buffer + mediaType: string + traceId: string + etag?: string +} + +export type RawOutcome = RawCompleteOutcome | NotModifiedOutcome + +export interface RelayClientFailure extends Error { + readonly kind: string + readonly code?: string + readonly status?: number + readonly traceId?: string + readonly retryAfterSeconds?: number + readonly transportKind?: string + readonly tokenKind?: string +} + +export declare class RelayClientError extends Error implements RelayClientFailure { + readonly kind: string + readonly code?: string + readonly status?: number + readonly traceId?: string + readonly retryAfterSeconds?: number + readonly transportKind?: string + readonly tokenKind?: string +} + +export declare class RelayClient { + constructor(config: RelayClientConfig) + health(): Promise> + ready(): Promise> + openapi(etag?: string | null): Promise + serviceMetadata(etag?: string | null): Promise> + resources(options?: ResourceListOptions | null, etag?: string | null): Promise + continueResources(continuation: ResourceContinuation, etag?: string | null): Promise + resource(resource: string, etag?: string | null): Promise> + listRecords(resource: string, options?: CollectionOptions | null, etag?: string | null): Promise + continueListRecords(continuation: CollectionContinuation, etag?: string | null): Promise + readRecord(resource: string, recordIdentifier: string, options?: RecordOptions | null, etag?: string | null): Promise> + lookup(resource: string, lookup: string, selectors: LookupSelectors, options?: RecordOptions | null, etag?: string | null): Promise> + search(resource: string, search: string, options?: CollectionOptions | null, etag?: string | null): Promise + continueSearch(continuation: CollectionContinuation, etag?: string | null): Promise + artifact(artifactIdentifier: string, etag?: string | null): Promise + sdmxData(request: SdmxDataRequest, etag?: string | null): Promise + sdmxStructure(request: SdmxStructureRequest, etag?: string | null): Promise +} diff --git a/crates/registry-relay-client-node/client.js b/crates/registry-relay-client-node/client.js new file mode 100644 index 000000000..fd335ec8b --- /dev/null +++ b/crates/registry-relay-client-node/client.js @@ -0,0 +1,171 @@ +'use strict'; + +const { types: { isProxy } } = require('node:util'); +const native = require('./index'); + +const MAX_JSON_DEPTH = 128; +const MAX_JSON_NODES = 100_000; +const MAX_JSON_STRING_BYTES = 4 * 1024 * 1024; + +class RelayClientError extends Error { + constructor(envelope) { + super(envelope.message); + this.name = 'RelayClientError'; + this.kind = envelope.kind; + for (const field of ['code', 'status', 'traceId', 'retryAfterSeconds', 'transportKind', 'tokenKind']) { + if (envelope[field] !== undefined) this[field] = envelope[field]; + } + } +} + +function normalize(error, fallbackKind) { + if (error instanceof RelayClientError) return error; + if (error instanceof Error && typeof error.message === 'string') { + try { + const envelope = JSON.parse(error.message); + if (envelope && typeof envelope === 'object' && typeof envelope.kind === 'string') { + return new RelayClientError(envelope); + } + } catch { + // napi argument conversion errors are not mapped Rust error envelopes. + } + } + if (fallbackKind) { + return new RelayClientError({ + kind: fallbackKind, + message: fallbackKind === 'configuration' + ? 'Relay client configuration is invalid' + : 'Relay client arguments are invalid', + }); + } + return error; +} + +function inputError(kind) { + return new RelayClientError({ + kind, + message: kind === 'configuration' + ? 'Relay client configuration is invalid' + : 'Relay client arguments are invalid', + }); +} + +function chargeString(value, budget, kind) { + budget.stringBytes += Buffer.byteLength(value, 'utf8'); + if (budget.stringBytes > MAX_JSON_STRING_BYTES) throw inputError(kind); +} + +function cloneJson(value, budget, depth, allowUndefined, kind) { + if (depth > MAX_JSON_DEPTH) throw inputError(kind); + budget.nodes += 1; + if (budget.nodes > MAX_JSON_NODES) throw inputError(kind); + + if (value === undefined) { + if (allowUndefined) return undefined; + throw inputError(kind); + } + if (value === null || typeof value === 'boolean') return value; + if (typeof value === 'string') { + chargeString(value, budget, kind); + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw inputError(kind); + return value; + } + if (typeof value !== 'object' || isProxy(value)) throw inputError(kind); + + const prototype = Object.getPrototypeOf(value); + const array = Array.isArray(value); + if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) { + throw inputError(kind); + } + if (budget.active.has(value)) throw inputError(kind); + budget.active.add(value); + + try { + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key === 'symbol')) throw inputError(kind); + + if (array) { + if (value.length + budget.nodes > MAX_JSON_NODES) throw inputError(kind); + const clone = new Array(value.length); + let elementCount = 0; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw inputError(kind); + if (key === 'length') continue; + chargeString(key, budget, kind); + const index = Number(key); + if (!descriptor.enumerable || !Number.isInteger(index) || index < 0 + || index >= value.length || String(index) !== key) { + throw inputError(kind); + } + clone[index] = cloneJson(descriptor.value, budget, depth + 1, false, kind); + elementCount += 1; + } + if (elementCount !== value.length) throw inputError(kind); + return clone; + } + + const clone = Object.create(null); + for (const key of keys) { + chargeString(key, budget, kind); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !Object.hasOwn(descriptor, 'value')) throw inputError(kind); + if (!descriptor.enumerable) continue; + clone[key] = cloneJson(descriptor.value, budget, depth + 1, false, kind); + } + return clone; + } finally { + budget.active.delete(value); + } +} + +function cloneArguments(args, kind, requiredJsonArguments = new Set()) { + const budget = { nodes: 0, stringBytes: 0, active: new WeakSet() }; + return args.map((value, index) => ( + cloneJson(value, budget, 0, !requiredJsonArguments.has(index), kind) + )); +} + +function wrapAsync(prototype, name, requiredJsonArguments) { + const original = prototype[name]; + prototype[name] = function (...args) { + try { + const clonedArgs = cloneArguments(args, 'invalid_request', requiredJsonArguments); + return original.apply(this, clonedArgs).catch((error) => { throw normalize(error); }); + } catch (error) { + throw normalize(error, 'invalid_request'); + } + }; +} + +const METHODS = [ + 'health', 'ready', 'openapi', 'serviceMetadata', 'resources', 'continueResources', 'resource', + 'listRecords', 'continueListRecords', 'readRecord', 'lookup', 'search', 'continueSearch', + 'artifact', 'sdmxData', 'sdmxStructure', +]; +const REQUIRED_JSON_ARGUMENTS = { + continueResources: new Set([0]), + continueListRecords: new Set([0]), + lookup: new Set([2]), + continueSearch: new Set([0]), + sdmxData: new Set([0]), + sdmxStructure: new Set([0]), +}; +for (const method of METHODS) { + wrapAsync(native.RelayClient.prototype, method, REQUIRED_JSON_ARGUMENTS[method]); +} + +class RelayClient extends native.RelayClient { + constructor(config) { + try { + super(...cloneArguments([config], 'configuration', new Set([0]))); + } catch (error) { + throw normalize(error, 'configuration'); + } + } +} + +module.exports = { RelayClient, RelayClientError }; diff --git a/crates/registry-relay-client-node/index.d.ts b/crates/registry-relay-client-node/index.d.ts new file mode 100644 index 000000000..a9c98bf47 --- /dev/null +++ b/crates/registry-relay-client-node/index.d.ts @@ -0,0 +1,58 @@ +/* auto-generated by NAPI-RS */ +/* eslint-disable */ +export declare class RelayClient { + constructor(configValue: any) + health(): Promise + ready(): Promise + openapi(etag?: string | undefined | null): Promise + serviceMetadata(etag?: string | undefined | null): Promise + resources(options?: any | undefined | null, etag?: string | undefined | null): Promise + continueResources(continuation: any, etag?: string | undefined | null): Promise + resource(resource: string, etag?: string | undefined | null): Promise + listRecords(resource: string, options?: any | undefined | null, etag?: string | undefined | null): Promise + continueListRecords(continuation: any, etag?: string | undefined | null): Promise + readRecord(resource: string, recordIdentifier: string, options?: any | undefined | null, etag?: string | undefined | null): Promise + lookup(resource: string, lookup: string, selectors: any, options?: any | undefined | null, etag?: string | undefined | null): Promise + search(resource: string, search: string, options?: any | undefined | null, etag?: string | undefined | null): Promise + continueSearch(continuation: any, etag?: string | undefined | null): Promise + artifact(artifactIdentifier: string, etag?: string | undefined | null): Promise + sdmxData(requestValue: any, etag?: string | undefined | null): Promise + sdmxStructure(requestValue: any, etag?: string | undefined | null): Promise +} + +export interface CollectionPageOutcome { + kind: string + value: any + continuation?: any + traceId: string + etag?: string +} + +export interface CompleteOutcome { + kind: string + value: any + traceId: string + etag?: string +} + +export interface NotModifiedOutcome { + kind: string + etag: string + traceId: string +} + +export interface RawCompleteOutcome { + kind: string + body: Buffer + mediaType: string + traceId: string + etag?: string +} + +export interface ResourcePageOutcome { + kind: string + value: any + continuation?: any + traceId: string + etag?: string +} diff --git a/crates/registry-relay-client-node/index.js b/crates/registry-relay-client-node/index.js new file mode 100644 index 000000000..f87ebcc4d --- /dev/null +++ b/crates/registry-relay-client-node/index.js @@ -0,0 +1,703 @@ +// prettier-ignore +/* eslint-disable */ +// @ts-nocheck +/* auto-generated by NAPI-RS */ + +const { readFileSync } = require('fs') +let nativeBinding = null +const loadErrors = [] + +const isMusl = () => { + let musl = false + if (process.platform === 'linux') { + musl = isMuslFromFilesystem() + if (musl === null) { + musl = isMuslFromReport() + } + if (musl === null) { + musl = isMuslFromChildProcess() + } + } + return musl +} + +const isFileMusl = (f) => f.includes('libc.musl-') || f.includes('ld-musl-') + +const isMuslFromFilesystem = () => { + try { + return readFileSync('/usr/bin/ldd', 'utf-8').includes('musl') + } catch { + return null + } +} + +const isMuslFromReport = () => { + let report = null + if (process.report && typeof process.report.getReport === 'function') { + process.report.excludeNetwork = true + report = process.report.getReport() + } + if (!report) { + return null + } + if (report.header && report.header.glibcVersionRuntime) { + return false + } + if (Array.isArray(report.sharedObjects)) { + if (report.sharedObjects.some(isFileMusl)) { + return true + } + } + return false +} + +const isMuslFromChildProcess = () => { + try { + return require('child_process').execSync('ldd --version', { encoding: 'utf8' }).includes('musl') + } catch (e) { + // If we reach this case, we don't know if the system is musl or not, so is better to just fallback to false + return false + } +} + +function requireNative() { + if (process.env.NAPI_RS_NATIVE_LIBRARY_PATH) { + try { + return require(process.env.NAPI_RS_NATIVE_LIBRARY_PATH); + } catch (err) { + loadErrors.push(err) + } + } else if (process.platform === 'android') { + if (process.arch === 'arm64') { + try { + return require('./relay-client.android-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-android-arm64') + const bindingPackageVersion = require('@registrystack/relay-client-android-arm64/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./relay-client.android-arm-eabi.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-android-arm-eabi') + const bindingPackageVersion = require('@registrystack/relay-client-android-arm-eabi/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Android ${process.arch}`)) + } + } else if (process.platform === 'win32') { + if (process.arch === 'x64') { + if ((process.config && process.config.variables && process.config.variables.shlib_suffix === 'dll.a') || (process.config && process.config.variables && process.config.variables.node_target_type === 'shared_library')) { + try { + return require('./relay-client.win32-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-win32-x64-gnu') + const bindingPackageVersion = require('@registrystack/relay-client-win32-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./relay-client.win32-x64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-win32-x64-msvc') + const bindingPackageVersion = require('@registrystack/relay-client-win32-x64-msvc/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ia32') { + try { + return require('./relay-client.win32-ia32-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-win32-ia32-msvc') + const bindingPackageVersion = require('@registrystack/relay-client-win32-ia32-msvc/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./relay-client.win32-arm64-msvc.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-win32-arm64-msvc') + const bindingPackageVersion = require('@registrystack/relay-client-win32-arm64-msvc/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Windows: ${process.arch}`)) + } + } else if (process.platform === 'darwin') { + try { + return require('./relay-client.darwin-universal.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-darwin-universal') + const bindingPackageVersion = require('@registrystack/relay-client-darwin-universal/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + if (process.arch === 'x64') { + try { + return require('./relay-client.darwin-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-darwin-x64') + const bindingPackageVersion = require('@registrystack/relay-client-darwin-x64/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./relay-client.darwin-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-darwin-arm64') + const bindingPackageVersion = require('@registrystack/relay-client-darwin-arm64/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on macOS: ${process.arch}`)) + } + } else if (process.platform === 'freebsd') { + if (process.arch === 'x64') { + try { + return require('./relay-client.freebsd-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-freebsd-x64') + const bindingPackageVersion = require('@registrystack/relay-client-freebsd-x64/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm64') { + try { + return require('./relay-client.freebsd-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-freebsd-arm64') + const bindingPackageVersion = require('@registrystack/relay-client-freebsd-arm64/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on FreeBSD: ${process.arch}`)) + } + } else if (process.platform === 'linux') { + if (process.arch === 'x64') { + if (isMusl()) { + try { + return require('./relay-client.linux-x64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-x64-musl') + const bindingPackageVersion = require('@registrystack/relay-client-linux-x64-musl/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./relay-client.linux-x64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-x64-gnu') + const bindingPackageVersion = require('@registrystack/relay-client-linux-x64-gnu/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm64') { + if (isMusl()) { + try { + return require('./relay-client.linux-arm64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-arm64-musl') + const bindingPackageVersion = require('@registrystack/relay-client-linux-arm64-musl/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./relay-client.linux-arm64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-arm64-gnu') + const bindingPackageVersion = require('@registrystack/relay-client-linux-arm64-gnu/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'arm') { + if (isMusl()) { + try { + return require('./relay-client.linux-arm-musleabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-arm-musleabihf') + const bindingPackageVersion = require('@registrystack/relay-client-linux-arm-musleabihf/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./relay-client.linux-arm-gnueabihf.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-arm-gnueabihf') + const bindingPackageVersion = require('@registrystack/relay-client-linux-arm-gnueabihf/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'loong64') { + if (isMusl()) { + try { + return require('./relay-client.linux-loong64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-loong64-musl') + const bindingPackageVersion = require('@registrystack/relay-client-linux-loong64-musl/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./relay-client.linux-loong64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-loong64-gnu') + const bindingPackageVersion = require('@registrystack/relay-client-linux-loong64-gnu/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'riscv64') { + if (isMusl()) { + try { + return require('./relay-client.linux-riscv64-musl.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-riscv64-musl') + const bindingPackageVersion = require('@registrystack/relay-client-linux-riscv64-musl/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + try { + return require('./relay-client.linux-riscv64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-riscv64-gnu') + const bindingPackageVersion = require('@registrystack/relay-client-linux-riscv64-gnu/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } + } else if (process.arch === 'ppc64') { + try { + return require('./relay-client.linux-ppc64-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-ppc64-gnu') + const bindingPackageVersion = require('@registrystack/relay-client-linux-ppc64-gnu/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 's390x') { + try { + return require('./relay-client.linux-s390x-gnu.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-linux-s390x-gnu') + const bindingPackageVersion = require('@registrystack/relay-client-linux-s390x-gnu/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on Linux: ${process.arch}`)) + } + } else if (process.platform === 'openharmony') { + if (process.arch === 'arm64') { + try { + return require('./relay-client.openharmony-arm64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-openharmony-arm64') + const bindingPackageVersion = require('@registrystack/relay-client-openharmony-arm64/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'x64') { + try { + return require('./relay-client.openharmony-x64.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-openharmony-x64') + const bindingPackageVersion = require('@registrystack/relay-client-openharmony-x64/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else if (process.arch === 'arm') { + try { + return require('./relay-client.openharmony-arm.node') + } catch (e) { + loadErrors.push(e) + } + try { + const binding = require('@registrystack/relay-client-openharmony-arm') + const bindingPackageVersion = require('@registrystack/relay-client-openharmony-arm/package.json').version + if (bindingPackageVersion !== '0.19.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + throw new Error(`Native binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + return binding + } catch (e) { + loadErrors.push(e) + } + } else { + loadErrors.push(new Error(`Unsupported architecture on OpenHarmony: ${process.arch}`)) + } + } else { + loadErrors.push(new Error(`Unsupported OS: ${process.platform}, architecture: ${process.arch}`)) + } +} + +function createLoadErrorChain(errors) { + return errors.reduce((previous, current) => { + let message + try { + message = + current && typeof current.message === 'string' + ? current.message + : String(current) + } catch { + message = 'Unknown error' + } + const error = new Error(message) + error.cause = previous + return error + }, null) +} + +// NAPI_RS_FORCE_WASI is a tri-state flag: +// unset / any other value → native binding preferred, WASI is only a fallback +// 'true' → prefer WASI, but retain native as a lazy fallback +// 'error' → require WASI without initializing a native fallback +// Treating any non-empty string as truthy (the historical behavior) meant +// NAPI_RS_FORCE_WASI=false, NAPI_RS_FORCE_WASI=0, etc. inadvertently triggered +// the WASI path, causing ENOENT for packages shipped without a .wasi.cjs file. +// +// NAPI_RS_WASI_FLAVOR selects one exact generated flavor and implies strict +// WASI loading. It never crosses into another flavor or falls back to native. +const __napiWasiFlavors = ["wasm32-wasi"] +const __napiWasiFlavor = process.env.NAPI_RS_WASI_FLAVOR +const __napiWasiFlavorRequested = + typeof __napiWasiFlavor === 'string' && __napiWasiFlavor.length > 0 +if ( + __napiWasiFlavorRequested && + __napiWasiFlavors.indexOf(__napiWasiFlavor) === -1 +) { + throw new Error( + 'Unsupported WASI flavor "' + + __napiWasiFlavor + + '". Available flavors: ' + + __napiWasiFlavors.join(', '), + ) +} +const forceWasiError = process.env.NAPI_RS_FORCE_WASI === 'error' +const forceWasi = + process.env.NAPI_RS_FORCE_WASI === 'true' || + forceWasiError || + __napiWasiFlavorRequested + +if (!forceWasi) { + nativeBinding = requireNative() +} + +if (!nativeBinding || forceWasi) { + let wasiBinding = null + let wasiBindingLoaded = false + const wasiBindingErrors = [] + const __napiWasiResolveCandidate = (specifier, isPackage, localArtifacts) => { + try { + require.resolve(specifier) + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + if (isPackage) { + try { + require.resolve(specifier + '/package.json') + } catch (packageError) { + if (packageError && packageError.code === 'MODULE_NOT_FOUND') { + return resolveError + } + // An exports restriction proves the package exists even when its + // package.json is not public. Preserve the root resolution failure. + throw resolveError + } + // The package exists but its main/export target is broken. + throw resolveError + } + return resolveError + } + if (localArtifacts) { + let artifactError = null + for (let i = 0; i < localArtifacts.length; i++) { + try { + require.resolve(localArtifacts[i]) + return null + } catch (resolveError) { + if (!resolveError || resolveError.code !== 'MODULE_NOT_FOUND') { + throw resolveError + } + artifactError = resolveError + } + } + return artifactError + } + return null + } + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + let candidateError = null + let candidateFailed = false + try { + candidateError = __napiWasiResolveCandidate('./relay-client.wasi.cjs', false, ["./relay-client.wasm32-wasi.debug.wasm","./relay-client.wasm32-wasi.wasm"]) + candidateFailed = candidateError !== null + if (!candidateFailed) { + wasiBinding = require('./relay-client.wasi.cjs') + nativeBinding = wasiBinding + wasiBindingLoaded = true + } + } catch (err) { + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) + } + } + if (!wasiBindingLoaded && (!__napiWasiFlavorRequested || __napiWasiFlavor === "wasm32-wasi")) { + let candidateError = null + let candidateFailed = false + try { + candidateError = __napiWasiResolveCandidate('@registrystack/relay-client-wasm32-wasi', true, undefined) + candidateFailed = candidateError !== null + if (!candidateFailed) { + if (process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') { + const bindingPackageVersion = require('@registrystack/relay-client-wasm32-wasi/package.json').version + if (bindingPackageVersion !== '0.19.0') { + throw new Error(`WASI binding package version mismatch, expected 0.19.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`) + } + } + wasiBinding = require('@registrystack/relay-client-wasm32-wasi') + nativeBinding = wasiBinding + wasiBindingLoaded = true + } + } catch (err) { + candidateError = err + candidateFailed = true + } + if (candidateFailed) { + wasiBindingErrors.push(candidateError) + loadErrors.push(candidateError) + } + } + if ( + !wasiBindingLoaded && + forceWasi && + !forceWasiError && + !__napiWasiFlavorRequested + ) { + nativeBinding = requireNative() + } + if ((forceWasiError || __napiWasiFlavorRequested) && !wasiBindingLoaded) { + const error = new Error( + __napiWasiFlavorRequested + ? 'WASI binding for flavor "' + __napiWasiFlavor + '" not found' + : 'WASI binding not found and NAPI_RS_FORCE_WASI is set to error', + ) + error.cause = createLoadErrorChain(wasiBindingErrors) + throw error + } +} + +if (!nativeBinding) { + if (loadErrors.length > 0) { + const error = new Error( + `Cannot find native binding. ` + + `npm has a bug related to optional dependencies (https://github.com/npm/cli/issues/4828). ` + + 'Please try `npm i` again after removing both package-lock.json and node_modules directory.', + ) + // assign instead of the `new Error(message, { cause })` options form, + // which Node < 16.9 silently ignores + error.cause = createLoadErrorChain(loadErrors) + throw error + } + throw new Error(`Failed to load native binding`) +} + +module.exports = nativeBinding +module.exports.RelayClient = nativeBinding.RelayClient diff --git a/crates/registry-relay-client-node/package-lock.json b/crates/registry-relay-client-node/package-lock.json new file mode 100644 index 000000000..c2e32b507 --- /dev/null +++ b/crates/registry-relay-client-node/package-lock.json @@ -0,0 +1,2024 @@ +{ + "name": "@registrystack/relay-client", + "version": "0.19.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@registrystack/relay-client", + "version": "0.19.0", + "license": "Apache-2.0", + "devDependencies": { + "@napi-rs/cli": "3.8.2", + "@types/node": "22.20.1", + "typescript": "6.0.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@inquirer/ansi": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", + "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.1.tgz", + "integrity": "sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", + "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.2.2.tgz", + "integrity": "sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/external-editor": "^3.0.3", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.1.tgz", + "integrity": "sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.3.tgz", + "integrity": "sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", + "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + } + }, + "node_modules/@inquirer/input": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.2.tgz", + "integrity": "sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-4.1.1.tgz", + "integrity": "sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-5.1.1.tgz", + "integrity": "sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.5.2.tgz", + "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^5.2.1", + "@inquirer/confirm": "^6.1.1", + "@inquirer/editor": "^5.2.2", + "@inquirer/expand": "^5.1.1", + "@inquirer/input": "^5.1.2", + "@inquirer/number": "^4.1.1", + "@inquirer/password": "^5.1.1", + "@inquirer/rawlist": "^5.3.1", + "@inquirer/search": "^4.2.1", + "@inquirer/select": "^5.2.1" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-5.3.1.tgz", + "integrity": "sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-4.2.1.tgz", + "integrity": "sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-5.2.1.tgz", + "integrity": "sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/core": "^11.2.1", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", + "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cli": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/@napi-rs/cli/-/cli-3.8.2.tgz", + "integrity": "sha512-iFmp3Lo/ipXoJI1I/6V/xU4hQV42bXS3A7j8C+Wt3LOtYY7HFCilkhyUkN1igLJFXWMICq95kNxhNcwzNRd+Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^8.5.2", + "@napi-rs/cross-toolchain": "^1.0.3", + "@napi-rs/wasm-tools": "^1.0.1", + "@octokit/rest": "^22.0.1", + "clipanion": "^4.0.0-rc.4", + "colorette": "^2.0.20", + "emnapi": "2.0.0-alpha.3", + "es-toolkit": "^1.47.0", + "js-yaml": "^4.2.0", + "obug": "^2.1.2", + "semver": "^7.8.2", + "typanion": "^3.14.0", + "typescript": "^6.0.3" + }, + "bin": { + "napi": "dist/cli.js", + "napi-raw": "cli.mjs" + }, + "engines": { + "node": "^20.17.0 || ^22.13.0 || >= 23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/runtime": "2.0.0-alpha.3" + }, + "peerDependenciesMeta": { + "@emnapi/runtime": { + "optional": true + } + } + }, + "node_modules/@napi-rs/cross-toolchain": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@napi-rs/cross-toolchain/-/cross-toolchain-1.0.3.tgz", + "integrity": "sha512-ENPfLe4937bsKVTDA6zdABx4pq9w0tHqRrJHyaGxgaPq03a2Bd1unD5XSKjXJjebsABJ+MjAv1A2OvCgK9yehg==", + "dev": true, + "license": "MIT", + "workspaces": [ + ".", + "arm64/*", + "x64/*" + ], + "dependencies": { + "@napi-rs/lzma": "^1.4.5", + "@napi-rs/tar": "^1.1.0", + "debug": "^4.4.1" + }, + "peerDependencies": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-arm64-target-x86_64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-aarch64": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-armv7": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-ppc64le": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-s390x": "^1.0.3", + "@napi-rs/cross-toolchain-x64-target-x86_64": "^1.0.3" + }, + "peerDependenciesMeta": { + "@napi-rs/cross-toolchain-arm64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-arm64-target-x86_64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-aarch64": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-armv7": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-ppc64le": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-s390x": { + "optional": true + }, + "@napi-rs/cross-toolchain-x64-target-x86_64": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.5.1.tgz", + "integrity": "sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.20 || ^24.12 || >=25" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/lzma-android-arm-eabi": "1.5.1", + "@napi-rs/lzma-android-arm64": "1.5.1", + "@napi-rs/lzma-darwin-arm64": "1.5.1", + "@napi-rs/lzma-darwin-x64": "1.5.1", + "@napi-rs/lzma-freebsd-x64": "1.5.1", + "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", + "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", + "@napi-rs/lzma-linux-arm64-musl": "1.5.1", + "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", + "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", + "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-musl": "1.5.1", + "@napi-rs/lzma-wasm32-wasi": "1.5.1", + "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", + "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", + "@napi-rs/lzma-win32-x64-msvc": "1.5.1" + } + }, + "node_modules/@napi-rs/lzma-android-arm-eabi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.5.1.tgz", + "integrity": "sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-android-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.5.1.tgz", + "integrity": "sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-darwin-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.5.1.tgz", + "integrity": "sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-darwin-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.5.1.tgz", + "integrity": "sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-freebsd-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.5.1.tgz", + "integrity": "sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.5.1.tgz", + "integrity": "sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.5.1.tgz", + "integrity": "sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.5.1.tgz", + "integrity": "sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.5.1.tgz", + "integrity": "sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.5.1.tgz", + "integrity": "sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-s390x-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.5.1.tgz", + "integrity": "sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.5.1.tgz", + "integrity": "sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.5.1.tgz", + "integrity": "sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/lzma-win32-arm64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.5.1.tgz", + "integrity": "sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-ia32-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.5.1.tgz", + "integrity": "sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.5.1.tgz", + "integrity": "sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/tar": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar/-/tar-1.1.1.tgz", + "integrity": "sha512-p6q2HhUc5vwH1CNwfOcrhLoxfgn8ust8Sqlfx+sA4VzAcp1cMbvbkl99tZZlDqOjCHgQNSiTfk/yWPjl/D42qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/tar-android-arm-eabi": "1.1.1", + "@napi-rs/tar-android-arm64": "1.1.1", + "@napi-rs/tar-darwin-arm64": "1.1.1", + "@napi-rs/tar-darwin-x64": "1.1.1", + "@napi-rs/tar-freebsd-x64": "1.1.1", + "@napi-rs/tar-linux-arm-gnueabihf": "1.1.1", + "@napi-rs/tar-linux-arm64-gnu": "1.1.1", + "@napi-rs/tar-linux-arm64-musl": "1.1.1", + "@napi-rs/tar-linux-ppc64-gnu": "1.1.1", + "@napi-rs/tar-linux-s390x-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-gnu": "1.1.1", + "@napi-rs/tar-linux-x64-musl": "1.1.1", + "@napi-rs/tar-wasm32-wasi": "1.1.1", + "@napi-rs/tar-win32-arm64-msvc": "1.1.1", + "@napi-rs/tar-win32-ia32-msvc": "1.1.1", + "@napi-rs/tar-win32-x64-msvc": "1.1.1" + } + }, + "node_modules/@napi-rs/tar-android-arm-eabi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm-eabi/-/tar-android-arm-eabi-1.1.1.tgz", + "integrity": "sha512-cAhnA10cSusAUbcE9HtjQY/tZ9BH/0w2sKtRcQc94TzIlnm7QSr1htJSd/PPrbWNPtrv1orXb2CkrHlVlbnlHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-android-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-android-arm64/-/tar-android-arm64-1.1.1.tgz", + "integrity": "sha512-EslUWHCDBY/g5abTPBiHLsMaML4GagV0TXLm5WL9hAjx/DDtlxz9fegMb77RJ+f7nFLOIsUxF/3QWFvgOT0sMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-arm64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-arm64/-/tar-darwin-arm64-1.1.1.tgz", + "integrity": "sha512-+A42/6ES5G9CQ35BOwzwA+WBjLID28r2jNPgc0dteD2hhClIhng0mva7D2ujUlXBNmgNOsr1LHn3stA4uTf4NQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-darwin-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-darwin-x64/-/tar-darwin-x64-1.1.1.tgz", + "integrity": "sha512-RYtE8w1dkEvj8hSJCDV5Jw0Rz2i13fsM7u893zv5O9n/4Ad5GNsw/f4RQ7/0YGSFaenkVxqPFrjmEvUHlKzsrg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-freebsd-x64": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-freebsd-x64/-/tar-freebsd-x64-1.1.1.tgz", + "integrity": "sha512-rEepBvCJUwcuvUYkY83e8aot8RsR5Jcnal4PsG3tbWGKW1yAvcXhyMXf0fN6ZGpVRZFnB+FJqDyBxvsCPEXKhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm-gnueabihf": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm-gnueabihf/-/tar-linux-arm-gnueabihf-1.1.1.tgz", + "integrity": "sha512-an1bJdfyhI5FpZYyTQ20mrqwR+a676i8GkaYc4Uy12dH/a7TJIfrK6Qa2Gm46arZvxUvx56qxoRKXbpOjUPvwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-gnu/-/tar-linux-arm64-gnu-1.1.1.tgz", + "integrity": "sha512-w++Vtx36T2yHTKws7GVnmHHcUT1ybB59xLWSh9A8bwEpJVG4dG7Qub9mFe5cpcbfrJ+XP2mKKxC3oUJSunK3iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-arm64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-arm64-musl/-/tar-linux-arm64-musl-1.1.1.tgz", + "integrity": "sha512-Rh6UFhNtj3i4deJHOBINFIeRL0072mgbeyuK5rl1HokKnNoMKx8qKIZNEzBTTqpogMfDHWGvzyTQdnVxes5dpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-ppc64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-ppc64-gnu/-/tar-linux-ppc64-gnu-1.1.1.tgz", + "integrity": "sha512-Cp+AxFbv9zcyAXtnzQi0OzmgDnQgy2w9D4Ubr+iwzMtVgJcztzcEoCcCrN1k2ATdEB01LX2Vb49IaocGOZhC9Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-s390x-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-s390x-gnu/-/tar-linux-s390x-gnu-1.1.1.tgz", + "integrity": "sha512-ZyscC3SYKTBWyDRYjLOKAd5TyJ7q0KACRdQ8bWrb3rgrra1CCIJD66CsGTH6Dh0AVSdfLwZ8MfIIXU6+14BMjQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-gnu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-gnu/-/tar-linux-x64-gnu-1.1.1.tgz", + "integrity": "sha512-LlIv+zg4fiOQge9LQX/ieBdRWE2fhVDjCTHxnunZkbugNmdhdelxWf1RpZb/6ZujWpNF4LPu4N/MW7ygg2oYAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-linux-x64-musl": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-linux-x64-musl/-/tar-linux-x64-musl-1.1.1.tgz", + "integrity": "sha512-gZBeoKLjanOVj55qk4EMu13P2i9M0SuINmlGQkOxm1niIJofexzddHUYtqO5o/5QqtyL8lADmAcZplLILMLhHA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-wasm32-wasi/-/tar-wasm32-wasi-1.1.1.tgz", + "integrity": "sha512-rwtQ1Mdt/ft6g6I54fJzbUeLspl4yTwj6I3UJ6mitKnrN42soJkcDrdh3Y/FGvlpqZTad2YMQ96fGJl3EtAm2Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/tar-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/tar-win32-arm64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-arm64-msvc/-/tar-win32-arm64-msvc-1.1.1.tgz", + "integrity": "sha512-30PVp1AehRpfwxmv5wI4cg0yj3WmWBsZ+1QnLGnvEELu7Eu/+dhNU0nrmhI7VfPgLwSRK2eg9DQTB3tP7Wv9bA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-ia32-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-ia32-msvc/-/tar-win32-ia32-msvc-1.1.1.tgz", + "integrity": "sha512-aI3/rmz+izUChiSeaPxcasAOxhf3FpJNuIHMXlxS/vpW+HIxUsSDR5+XV61PEG5DL4L/75iENVUxmSGM5l2yaw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/tar-win32-x64-msvc": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/tar-win32-x64-msvc/-/tar-win32-x64-msvc-1.1.1.tgz", + "integrity": "sha512-yJsB2IsrODQVLKbm2Fg1nHiVRbEj49mSPbj4x7JPZWJI0jGVPjohE2Sif0FBbx8OxsVoUODvS0BwksZZ8jl/OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@napi-rs/wasm-tools": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools/-/wasm-tools-1.1.0.tgz", + "integrity": "sha512-VjHyKEqXAwYZK+HY7iJctYvRm3TFEbaQxeZwvAG1QRkoo1a39phMY8J6x9tUEqJI03W6MysB8F2jacI6wvcx+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.22.0" + }, + "optionalDependencies": { + "@napi-rs/wasm-tools-android-arm-eabi": "1.1.0", + "@napi-rs/wasm-tools-android-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-arm64": "1.1.0", + "@napi-rs/wasm-tools-darwin-x64": "1.1.0", + "@napi-rs/wasm-tools-freebsd-x64": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-arm64-musl": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-gnu": "1.1.0", + "@napi-rs/wasm-tools-linux-x64-musl": "1.1.0", + "@napi-rs/wasm-tools-wasm32-wasi": "1.1.0", + "@napi-rs/wasm-tools-win32-arm64-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-ia32-msvc": "1.1.0", + "@napi-rs/wasm-tools-win32-x64-msvc": "1.1.0" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm-eabi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm-eabi/-/wasm-tools-android-arm-eabi-1.1.0.tgz", + "integrity": "sha512-p6J8PB59I8d/XItXB/go5JH6nKW+xIbpzaL43EBTV0hi7mrS/Z4gs+MsB04ZrlqZN29BdZV8fChRyasuXLhRaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-android-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-android-arm64/-/wasm-tools-android-arm64-1.1.0.tgz", + "integrity": "sha512-lWoKN3suypeBSCIRPIw+++sH9V2K6nQkhtdt1opu7XY3v9JwLs6Gw063HWRqkNjphlYpkd/Qy8XcfSPGbJj7nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-arm64/-/wasm-tools-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-jfw5vyNDUf6oe0kP8lMveFN9U7cLk1cUosS7uMIfw/xmqmopYfKQ198DAx2g/6aEF7Tm+CqER2gpMpYKui30LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-darwin-x64/-/wasm-tools-darwin-x64-1.1.0.tgz", + "integrity": "sha512-R+pjeudAB7BYdH1vKkOJM61Tfv5jB6uXkxmFscYd+KKpdUpWBlNG+s4hr0w4i1rMBM91VhIAETZn2pz+MDHK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-freebsd-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-freebsd-x64/-/wasm-tools-freebsd-x64-1.1.0.tgz", + "integrity": "sha512-hQJTe+aazrT++Vgm6I4lUd9099ItUCFYdd+aKg6Ys6nax6d/cZ1barDLTwA2lwOoVDsXMekJI/FOL6ZvVlIYBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-gnu/-/wasm-tools-linux-arm64-gnu-1.1.0.tgz", + "integrity": "sha512-1TAXJxUHsWGar90k3W/MknavvBMwOWzjh7Q6Spxo8twRcWJbBD5Kow/Q2KhhDq5hxh2sKGDXn3uLc1tdtz4WUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-arm64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-arm64-musl/-/wasm-tools-linux-arm64-musl-1.1.0.tgz", + "integrity": "sha512-7rw3nlubTjNAVRH2LwphCxHy1b/N2/TerXocQ6XRn4Q+buaY1Z7P/hbdALy1i1ex2yfOU2Xcij7ib7ZLi/lKfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-gnu": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-gnu/-/wasm-tools-linux-x64-gnu-1.1.0.tgz", + "integrity": "sha512-1sel0t9MRjI/tdT89M8Dd6gPfANeeFP24Xa46R11WeHNwhjsXXZh+xUk50uWCRTSGcaCy3ugm3AMK/lmHYQJkg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-linux-x64-musl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-linux-x64-musl/-/wasm-tools-linux-x64-musl-1.1.0.tgz", + "integrity": "sha512-o2jH5AMfor4EKF2HII1LBnMQxoWu7+usPifTEY8Zk6e9OiSi4EJkAXf9v3ANlX7TI2V/cUEV34OEW7r10GiVIA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-wasm32-wasi/-/wasm-tools-wasm32-wasi-1.1.0.tgz", + "integrity": "sha512-s6YDtDR1UWrsqJPtaxf+JLYLceWVyn3l8OpQYElHkDhf3Qfz9R6Ba3S0OgznTBv38L5/TIHysQ9Q4yO73Z0csg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-arm64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-arm64-msvc/-/wasm-tools-win32-arm64-msvc-1.1.0.tgz", + "integrity": "sha512-x+NuxbG84VxU68tU8w7Rf5lSyq0l584M6dVlke5DTweHYFZoMyeqkpbwEq+qsyAX6ivfipK8xRsmFwamb5uDnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-ia32-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-ia32-msvc/-/wasm-tools-win32-ia32-msvc-1.1.0.tgz", + "integrity": "sha512-mdD96QDEp70SX67rXFTY6c725nVYeqEEjyDqzzbNh6u1APj7CI7IMNpMmvE75XbCRl4C2MHZVU4U6AWdAzvyQQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@napi-rs/wasm-tools-win32-x64-msvc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-tools-win32-x64-msvc/-/wasm-tools-win32-x64-msvc-1.1.0.tgz", + "integrity": "sha512-bVVjuvhlyVX++3eJXfDR63cXdw1ay5QYac6iq0MKQw8wZARInTM+bXCtByDT4fzVFI3+7ZthYb/ERWRdBNIqgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.22.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.7.tgz", + "integrity": "sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.4", + "@octokit/request": "^10.0.13", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.4.tgz", + "integrity": "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^17.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.4.tgz", + "integrity": "sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.13", + "@octokit/types": "^17.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-28.0.0.tgz", + "integrity": "sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.13", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.13.tgz", + "integrity": "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.1.1", + "@octokit/types": "^17.0.0", + "content-type": "^2.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.1.tgz", + "integrity": "sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/types": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-17.0.0.tgz", + "integrity": "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^28.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/emnapi": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/emnapi/-/emnapi-2.0.0-alpha.3.tgz", + "integrity": "sha512-K9bc9Xx4OwSfhJpdSOpcfIKzn7/6emuubaIorf6I5e7WBAM79665rf6iHr9y50NL4qYMUP/AheTpD1Z4yU1EBw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "node-addon-api": ">= 6.1.0" + }, + "peerDependenciesMeta": { + "node-addon-api": { + "optional": true + } + } + }, + "node_modules/es-toolkit": { + "version": "1.50.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.50.0.tgz", + "integrity": "sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types" + ] + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-with-bigint": { + "version": "3.5.10", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.10.tgz", + "integrity": "sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", + "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/crates/registry-relay-client-node/package.json b/crates/registry-relay-client-node/package.json new file mode 100644 index 000000000..fa63900ed --- /dev/null +++ b/crates/registry-relay-client-node/package.json @@ -0,0 +1,25 @@ +{ + "name": "@registrystack/relay-client", + "version": "0.19.0", + "description": "Node.js binding for the Registry Relay V2 client, via napi-rs.", + "license": "Apache-2.0", + "repository": { "type": "git", "url": "https://github.com/registrystack/registry-stack" }, + "main": "client.js", + "types": "client.d.ts", + "exports": { ".": { "types": "./client.d.ts", "default": "./client.js" } }, + "private": true, + "files": ["client.js", "client.d.ts", "index.js", "index.d.ts", "*.node"], + "napi": { "binaryName": "relay-client" }, + "engines": { "node": ">=22.12.0" }, + "scripts": { + "build": "napi build --platform --release", + "build:debug": "napi build --platform", + "test": "node --test __test__/*.test.js", + "check:types": "napi build --platform --release --dts index.d.ts.check && cmp index.d.ts index.d.ts.check && rm -f index.d.ts.check && tsc --noEmit --strict --skipLibCheck false --types node --moduleResolution node16 --module node16 --target es2022 client.d.ts index.d.ts" + }, + "devDependencies": { + "@napi-rs/cli": "3.8.2", + "@types/node": "22.20.1", + "typescript": "6.0.3" + } +} diff --git a/crates/registry-relay-client-node/src/lib.rs b/crates/registry-relay-client-node/src/lib.rs new file mode 100644 index 000000000..b436ac79c --- /dev/null +++ b/crates/registry-relay-client-node/src/lib.rs @@ -0,0 +1,1241 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Node.js binding for the canonical Registry Relay V2 client. + +#![deny(unsafe_code)] + +use std::{collections::BTreeMap, sync::Arc, time::Duration}; + +use napi::{ + bindgen_prelude::{Buffer, Either}, + Error as NapiError, Result, +}; +use napi_derive::napi; +use registry_platform_crypto::PrivateJwk; +use registry_relay_client::{ + BoundingBox, CollectionContinuation, CollectionContinuationProjection, CollectionPage, + CollectionRequest, CollectionRouteProjection, Complete, Conditional, LookupRequest, + NotModified, PrivateKeyJwt, PrivateKeyJwtConfig, ProtocolFailure, RawDocument, RecordFormat, + RecordOptions, RelayClient as CoreClient, RelayClientConfig, RelayClientError, + ResourceContinuation, ResourceContinuationProjection, ResourceListRequest, ResourcePage, + ResponseMetadata, SdmxDataFormat, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, + StaticToken, StrongEtag, TokenError, TokenProvider, +}; +use serde::Serialize; +use serde_json::{json, Map, Value}; +use url::Url; + +type JsonOutcome = Either; +type ResourceOutcome = Either; +type CollectionOutcome = Either; +type RawOutcome = Either; + +#[napi(object)] +pub struct CompleteOutcome { + pub kind: String, + pub value: Value, + pub trace_id: String, + pub etag: Option, +} + +#[napi(object)] +pub struct ResourcePageOutcome { + pub kind: String, + pub value: Value, + pub continuation: Option, + pub trace_id: String, + pub etag: Option, +} + +#[napi(object)] +pub struct CollectionPageOutcome { + pub kind: String, + pub value: Value, + pub continuation: Option, + pub trace_id: String, + pub etag: Option, +} + +#[napi(object)] +pub struct RawCompleteOutcome { + pub kind: String, + pub body: Buffer, + pub media_type: String, + pub trace_id: String, + pub etag: Option, +} + +#[napi(object)] +pub struct NotModifiedOutcome { + pub kind: String, + pub etag: String, + pub trace_id: String, +} + +fn mapped_error(value: Value) -> NapiError { + NapiError::from_reason(serde_json::to_string(&value).unwrap_or_else(|_| { + r#"{"kind":"protocol","message":"the failure could not be described"}"#.to_owned() + })) +} + +fn binding_error(kind: &'static str, message: &'static str) -> NapiError { + mapped_error(json!({"kind": kind, "message": message})) +} + +fn protocol_code(value: ProtocolFailure) -> &'static str { + match value { + ProtocolFailure::HeaderBounds => "header_bounds", + ProtocolFailure::TraceContext => "trace_context", + ProtocolFailure::MediaType => "media_type", + ProtocolFailure::Body => "body", + ProtocolFailure::Problem => "problem", + ProtocolFailure::EntityTag => "entity_tag", + ProtocolFailure::NotModifiedBody => "not_modified_body", + ProtocolFailure::Status => "status", + _ => "protocol", + } +} + +fn client_error(error: RelayClientError) -> NapiError { + let value = match error { + RelayClientError::Configuration { reason } => { + json!({"kind": "configuration", "message": reason}) + } + RelayClientError::InvalidRequest { reason } => { + json!({"kind": "invalid_request", "message": reason}) + } + RelayClientError::Transport { kind } => { + json!({"kind": "transport", "transportKind": kind.kind(), "message": "Relay exchange did not complete"}) + } + RelayClientError::Problem { + status, + code, + trace_id, + retry_after_seconds, + } => json!({ + "kind": "problem", + "status": status, + "code": code.code(), + "traceId": trace_id.as_str(), + "retryAfterSeconds": retry_after_seconds, + "message": "Relay refused the request" + }), + RelayClientError::Protocol { + status, + failure, + trace_id, + } => json!({ + "kind": "protocol", + "status": status, + "code": protocol_code(failure), + "traceId": trace_id.map(|value| value.as_str().to_owned()), + "message": failure.to_string() + }), + RelayClientError::Token(error) => token_error_value(error), + _ => json!({"kind": "client", "message": "Relay client returned an unsupported failure"}), + }; + mapped_error(value) +} + +fn token_error_value(error: TokenError) -> Value { + let mut value = json!({ + "kind": "token", + "tokenKind": error.kind(), + "message": error.to_string() + }); + let object = value + .as_object_mut() + .expect("the token error envelope is an object"); + match error { + TokenError::Transport { kind } => { + object.insert("transportKind".into(), Value::String(kind.kind().into())); + } + TokenError::Refused { code } => { + object.insert("code".into(), Value::String(code.as_str().into())); + } + TokenError::Protocol { status } => { + object.insert("status".into(), Value::from(status)); + } + _ => {} + } + value +} + +fn serialization_error() -> NapiError { + binding_error( + "protocol", + "a Relay client result could not be represented for JavaScript", + ) +} + +fn required_object<'a>(value: &'a Value, what: &'static str) -> Result<&'a Map> { + value + .as_object() + .ok_or_else(|| binding_error("configuration", what)) +} + +fn only_fields( + object: &Map, + allowed: &[&str], + kind: &'static str, + message: &'static str, +) -> Result<()> { + if object + .keys() + .any(|field| !allowed.contains(&field.as_str())) + { + return Err(binding_error(kind, message)); + } + Ok(()) +} + +fn required_string( + object: &Map, + field: &str, + kind: &'static str, + message: &'static str, +) -> Result { + object + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| binding_error(kind, message)) +} + +fn optional_string( + object: &Map, + field: &str, + kind: &'static str, + message: &'static str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(binding_error(kind, message)), + } +} + +fn optional_u64( + object: &Map, + field: &str, + kind: &'static str, + message: &'static str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_u64() + .map(Some) + .ok_or_else(|| binding_error(kind, message)), + } +} + +fn optional_i64( + object: &Map, + field: &str, + message: &'static str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_i64() + .map(Some) + .ok_or_else(|| binding_error("configuration", message)), + } +} + +fn private_key_jwt(value: &Value) -> Result { + let object = required_object(value, "authorization.privateKeyJwt must be an object")?; + only_fields( + object, + &[ + "tokenEndpoint", + "clientId", + "clientKey", + "audience", + "assertionLifetimeSeconds", + "refreshMarginSeconds", + "requestTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "authorization.privateKeyJwt contains an unsupported field", + )?; + let endpoint = required_string( + object, + "tokenEndpoint", + "configuration", + "authorization.privateKeyJwt.tokenEndpoint must be a string", + )?; + let endpoint = Url::parse(&endpoint).map_err(|_| { + binding_error( + "configuration", + "authorization.privateKeyJwt.tokenEndpoint must be a URL", + ) + })?; + let client_id = required_string( + object, + "clientId", + "configuration", + "authorization.privateKeyJwt.clientId must be a string", + )?; + let key = object.get("clientKey").ok_or_else(|| { + binding_error( + "configuration", + "authorization.privateKeyJwt.clientKey must be present", + ) + })?; + let key = serde_json::to_string(key).map_err(|_| { + binding_error( + "configuration", + "authorization.privateKeyJwt.clientKey is invalid", + ) + })?; + let key = PrivateJwk::parse(&key).map_err(|_| { + binding_error( + "configuration", + "authorization.privateKeyJwt.clientKey is invalid", + ) + })?; + + let mut config = PrivateKeyJwtConfig::new(endpoint, client_id, key); + if let Some(value) = optional_string( + object, + "audience", + "configuration", + "authorization.privateKeyJwt.audience must be a string", + )? { + config = config.with_audience(value); + } + if let Some(value) = optional_i64( + object, + "assertionLifetimeSeconds", + "authorization.privateKeyJwt.assertionLifetimeSeconds must be an integer", + )? { + config = config.with_assertion_lifetime_seconds(value); + } + if let Some(value) = optional_i64( + object, + "refreshMarginSeconds", + "authorization.privateKeyJwt.refreshMarginSeconds must be an integer", + )? { + config = config.with_refresh_margin_seconds(value); + } + if let Some(value) = optional_u64( + object, + "requestTimeoutMilliseconds", + "configuration", + "authorization.privateKeyJwt.requestTimeoutMilliseconds must be a non-negative integer", + )? { + config = config.with_request_timeout(Duration::from_millis(value)); + } + if let Some(value) = optional_u64( + object, + "connectTimeoutMilliseconds", + "configuration", + "authorization.privateKeyJwt.connectTimeoutMilliseconds must be a non-negative integer", + )? { + config = config.with_connect_timeout(Duration::from_millis(value)); + } + if let Some(value) = optional_string( + object, + "userAgent", + "configuration", + "authorization.privateKeyJwt.userAgent must be a string", + )? { + config = config.with_user_agent(value); + } + if let Some(value) = optional_string( + object, + "trustedRootCertificates", + "configuration", + "authorization.privateKeyJwt.trustedRootCertificates must be a string", + )? { + config = config.with_trusted_root_certificates(value.into_bytes()); + } + PrivateKeyJwt::new(config).map_err(|error| mapped_error(token_error_value(error))) +} + +fn authorization_provider(value: &Value) -> Result>> { + if value.is_null() { + return Ok(None); + } + let object = required_object(value, "authorization must be an object")?; + if object.len() != 1 { + return Err(binding_error( + "configuration", + "authorization must contain exactly one of static or privateKeyJwt", + )); + } + if let Some(value) = object.get("static") { + let token = value.as_str().ok_or_else(|| { + binding_error("configuration", "authorization.static must be a string") + })?; + return StaticToken::new(token) + .map(|provider| Some(Arc::new(provider) as Arc)) + .map_err(|error| mapped_error(token_error_value(error))); + } + if let Some(value) = object.get("privateKeyJwt") { + return private_key_jwt(value) + .map(|provider| Some(Arc::new(provider) as Arc)); + } + Err(binding_error( + "configuration", + "authorization must contain exactly one of static or privateKeyJwt", + )) +} + +fn config(value: Value) -> Result { + let object = required_object(&value, "client configuration must be an object")?; + only_fields( + object, + &[ + "baseUrl", + "authorization", + "requestTimeoutMilliseconds", + "connectTimeoutMilliseconds", + "maxResponseBytes", + "userAgent", + "trustedRootCertificates", + ], + "configuration", + "client configuration contains an unsupported field", + )?; + let base_url = required_string( + object, + "baseUrl", + "configuration", + "baseUrl must be a string", + )?; + let base_url = Url::parse(&base_url) + .map_err(|_| binding_error("configuration", "baseUrl must be a URL"))?; + let mut config = RelayClientConfig::new(base_url); + if let Some(value) = object.get("authorization") { + if let Some(provider) = authorization_provider(value)? { + config = config.with_token_provider(provider); + } + } + if let Some(value) = optional_u64( + object, + "requestTimeoutMilliseconds", + "configuration", + "requestTimeoutMilliseconds must be a non-negative integer", + )? { + config = config.with_request_timeout(Duration::from_millis(value)); + } + if let Some(value) = optional_u64( + object, + "connectTimeoutMilliseconds", + "configuration", + "connectTimeoutMilliseconds must be a non-negative integer", + )? { + config = config.with_connect_timeout(Duration::from_millis(value)); + } + if let Some(value) = optional_u64( + object, + "maxResponseBytes", + "configuration", + "maxResponseBytes must be a non-negative integer", + )? { + config = config.with_max_response_bytes(value); + } + if let Some(value) = optional_string( + object, + "userAgent", + "configuration", + "userAgent must be a string", + )? { + config = config.with_user_agent(value); + } + if let Some(value) = optional_string( + object, + "trustedRootCertificates", + "configuration", + "trustedRootCertificates must be a string", + )? { + config = config.with_trusted_root_certificates(value.into_bytes()); + } + CoreClient::new(config).map_err(client_error) +} + +fn parse_etag(value: Option) -> Result> { + value + .as_deref() + .map(StrongEtag::parse) + .transpose() + .map_err(|_| { + binding_error( + "invalid_request", + "etag must be a strong quoted SHA-256 entity tag", + ) + }) +} + +fn metadata_parts(metadata: &ResponseMetadata) -> (String, Option) { + ( + metadata.trace_id().as_str().to_owned(), + metadata.etag().map(|value| value.as_str().to_owned()), + ) +} + +fn complete_value( + value: T, + metadata_value: ResponseMetadata, +) -> Result { + let (trace_id, etag) = metadata_parts(&metadata_value); + Ok(CompleteOutcome { + kind: "complete".into(), + value: serde_json::to_value(value).map_err(|_| serialization_error())?, + trace_id, + etag, + }) +} + +fn not_modified(value: NotModified) -> NotModifiedOutcome { + NotModifiedOutcome { + kind: "notModified".into(), + etag: value.etag.as_str().to_owned(), + trace_id: value.trace_id.as_str().to_owned(), + } +} + +fn conditional_value(value: Conditional) -> Result { + match value { + Conditional::Complete(Complete { value, metadata }) => { + complete_value(value, metadata).map(Either::A) + } + Conditional::NotModified(value) => Ok(Either::B(not_modified(value))), + } +} + +fn resource_page(value: Conditional>) -> Result { + match value { + Conditional::NotModified(value) => Ok(Either::B(not_modified(value))), + Conditional::Complete(Complete { value, metadata }) => { + let (trace_id, etag) = metadata_parts(&metadata); + Ok(Either::A(ResourcePageOutcome { + kind: "complete".into(), + value: serde_json::to_value(value.value).map_err(|_| serialization_error())?, + continuation: value + .continuation + .map(|value| serde_json::to_value(value.projection())) + .transpose() + .map_err(|_| serialization_error())?, + trace_id, + etag, + })) + } + } +} + +fn collection_page( + value: Conditional>, +) -> Result { + match value { + Conditional::NotModified(value) => Ok(Either::B(not_modified(value))), + Conditional::Complete(Complete { value, metadata }) => { + let (trace_id, etag) = metadata_parts(&metadata); + let continuation = value + .continuation + .map(|value| serde_json::to_value(value.projection())) + .transpose() + .map_err(|_| serialization_error())?; + Ok(Either::A(CollectionPageOutcome { + kind: "complete".into(), + value: serde_json::to_value(value.value).map_err(|_| serialization_error())?, + continuation, + trace_id, + etag, + })) + } + } +} + +fn conditional_raw(value: Conditional) -> RawOutcome { + match value { + Conditional::NotModified(value) => Either::B(not_modified(value)), + Conditional::Complete(Complete { value, metadata }) => { + let (trace_id, etag) = metadata_parts(&metadata); + Either::A(RawCompleteOutcome { + kind: "complete".into(), + body: value.as_bytes().to_vec().into(), + media_type: value.media_type().to_owned(), + trace_id, + etag, + }) + } + } +} + +fn request_object( + value: Option, + allowed: &[&str], + message: &'static str, +) -> Result> { + match value { + None | Some(Value::Null) => Ok(Map::new()), + Some(Value::Object(object)) => { + only_fields(&object, allowed, "invalid_request", message)?; + Ok(object) + } + Some(_) => Err(binding_error("invalid_request", message)), + } +} + +fn record_format(value: Option) -> Result { + match value.as_deref().unwrap_or("json") { + "json" => Ok(RecordFormat::Json), + "json-ld" => Ok(RecordFormat::JsonLd), + "geojson" | "geo-json-rfc7946" => Ok(RecordFormat::GeoJsonRfc7946), + "json-fg" => Ok(RecordFormat::JsonFg), + _ => Err(binding_error( + "invalid_request", + "format must be json, json-ld, geojson, or json-fg", + )), + } +} + +fn request_optional_string( + object: &Map, + field: &str, + message: &'static str, +) -> Result> { + optional_string(object, field, "invalid_request", message) +} + +fn request_optional_u32( + object: &Map, + field: &str, + message: &'static str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .map(Some) + .ok_or_else(|| binding_error("invalid_request", message)), + } +} + +fn string_array( + object: &Map, + field: &str, + message: &'static str, +) -> Result>> { + match object.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(values)) => values + .iter() + .map(|value| value.as_str().map(str::to_owned)) + .collect::>>() + .map(Some) + .ok_or_else(|| binding_error("invalid_request", message)), + Some(_) => Err(binding_error("invalid_request", message)), + } +} + +fn string_map( + object: &Map, + field: &str, + message: &'static str, +) -> Result> { + match object.get(field) { + None | Some(Value::Null) => Ok(BTreeMap::new()), + Some(Value::Object(values)) => values + .iter() + .map(|(name, value)| { + value + .as_str() + .map(|value| (name.clone(), value.to_owned())) + .ok_or_else(|| binding_error("invalid_request", message)) + }) + .collect(), + Some(_) => Err(binding_error("invalid_request", message)), + } +} + +fn record_options(object: &Map) -> Result { + let format = record_format(request_optional_string( + object, + "format", + "format must be a string", + )?)?; + let mut options = RecordOptions::default().format(format); + if let Some(fields) = string_array(object, "fields", "fields must be an array of strings")? { + options = options.fields(fields).map_err(client_error)?; + } + if let Some(value) = + request_optional_string(object, "accessProfile", "accessProfile must be a string")? + { + options = options.access_profile(value).map_err(client_error)?; + } + Ok(options) +} + +fn collection_request(value: Option) -> Result { + let object = request_object( + value, + &[ + "pageSize", + "fields", + "accessProfile", + "format", + "filters", + "bbox", + ], + "collection options must be an object with supported fields", + )?; + let mut request = CollectionRequest::default().options(record_options(&object)?); + if let Some(value) = request_optional_u32( + &object, + "pageSize", + "pageSize must be a non-negative integer", + )? { + request = request.page_size(value).map_err(client_error)?; + } + for (name, value) in string_map(&object, "filters", "filters must map strings to strings")? { + request = request.filter(name, value).map_err(client_error)?; + } + if let Some(value) = object.get("bbox") { + if !value.is_null() { + let values = value.as_array().ok_or_else(|| { + binding_error("invalid_request", "bbox must be an array of four numbers") + })?; + let numbers = values + .iter() + .map(Value::as_f64) + .collect::>>() + .filter(|values| values.len() == 4) + .ok_or_else(|| { + binding_error("invalid_request", "bbox must be an array of four numbers") + })?; + request = request.bbox( + BoundingBox::new(numbers[0], numbers[1], numbers[2], numbers[3]) + .map_err(client_error)?, + ); + } + } + Ok(request) +} + +fn record_options_request(value: Option) -> Result { + let object = request_object( + value, + &["fields", "accessProfile", "format"], + "record options must be an object with supported fields", + )?; + record_options(&object) +} + +fn collection_continuation(value: Value, expected: &'static str) -> Result { + let object = value + .as_object() + .ok_or_else(|| binding_error("invalid_request", "continuation must be an object"))?; + only_fields( + object, + &["route", "cursor", "format", "accessProfile"], + "invalid_request", + "continuation is invalid", + )?; + let projection: CollectionContinuationProjection = serde_json::from_value(value) + .map_err(|_| binding_error("invalid_request", "continuation is invalid"))?; + let matches = matches!( + (&projection.route, expected), + (CollectionRouteProjection::Records { .. }, "records") + | (CollectionRouteProjection::Search { .. }, "search") + ); + if !matches { + return Err(binding_error( + "invalid_request", + "continuation does not match the method that consumes it", + )); + } + CollectionContinuation::try_from_projection(projection).map_err(client_error) +} + +fn resource_continuation(value: Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| binding_error("invalid_request", "resource continuation is invalid"))?; + only_fields( + object, + &["cursor"], + "invalid_request", + "resource continuation is invalid", + )?; + let projection: ResourceContinuationProjection = serde_json::from_value(value) + .map_err(|_| binding_error("invalid_request", "resource continuation is invalid"))?; + ResourceContinuation::try_from_projection(projection).map_err(client_error) +} + +#[napi] +pub struct RelayClient { + inner: Arc, +} + +#[napi] +impl RelayClient { + #[napi(constructor)] + pub fn new(config_value: Value) -> Result { + Ok(Self { + inner: Arc::new(config(config_value)?), + }) + } + + #[napi] + pub async fn health(&self) -> Result { + let Complete { value, metadata } = self.inner.health().await.map_err(client_error)?; + complete_value(value, metadata) + } + + #[napi] + pub async fn ready(&self) -> Result { + let Complete { value, metadata } = self.inner.ready().await.map_err(client_error)?; + complete_value(value, metadata) + } + + #[napi] + pub async fn openapi( + &self, + etag: Option, + ) -> Result> { + let etag = parse_etag(etag)?; + Ok(conditional_raw( + self.inner + .openapi(etag.as_ref()) + .await + .map_err(client_error)?, + )) + } + + #[napi] + pub async fn service_metadata( + &self, + etag: Option, + ) -> Result> { + let etag = parse_etag(etag)?; + conditional_value( + self.inner + .service_metadata(etag.as_ref()) + .await + .map_err(client_error)?, + ) + } + + #[napi] + pub async fn resources( + &self, + options: Option, + etag: Option, + ) -> Result> { + let object = request_object( + options, + &["pageSize"], + "resource list options must contain only pageSize", + )?; + let mut request = ResourceListRequest::default(); + if let Some(value) = request_optional_u32( + &object, + "pageSize", + "pageSize must be a non-negative integer", + )? { + request = request.page_size(value).map_err(client_error)?; + } + let etag = parse_etag(etag)?; + resource_page( + self.inner + .resources(request, etag.as_ref()) + .await + .map_err(client_error)?, + ) + } + + #[napi] + pub async fn continue_resources( + &self, + continuation: Value, + etag: Option, + ) -> Result> { + let continuation = resource_continuation(continuation)?; + let etag = parse_etag(etag)?; + resource_page( + self.inner + .continue_resources(&continuation, etag.as_ref()) + .await + .map_err(client_error)?, + ) + } + + #[napi] + pub async fn resource( + &self, + resource: String, + etag: Option, + ) -> Result> { + let etag = parse_etag(etag)?; + conditional_value( + self.inner + .resource(&resource, etag.as_ref()) + .await + .map_err(client_error)?, + ) + } + + #[napi] + pub async fn list_records( + &self, + resource: String, + options: Option, + etag: Option, + ) -> Result> { + let request = collection_request(options)?; + let etag = parse_etag(etag)?; + collection_page( + self.inner + .list_records(&resource, &request, etag.as_ref()) + .await + .map_err(client_error)?, + ) + } + + #[napi] + pub async fn continue_list_records( + &self, + continuation: Value, + etag: Option, + ) -> Result> { + self.continue_collection(continuation, etag, "records") + .await + } + + #[napi] + pub async fn read_record( + &self, + resource: String, + record_identifier: String, + options: Option, + etag: Option, + ) -> Result> { + let options = record_options_request(options)?; + let etag = parse_etag(etag)?; + conditional_value( + self.inner + .read_record(&resource, &record_identifier, &options, etag.as_ref()) + .await + .map_err(client_error)?, + ) + } + + #[napi] + pub async fn lookup( + &self, + resource: String, + lookup: String, + selectors: Value, + options: Option, + etag: Option, + ) -> Result> { + let selectors = selectors + .as_object() + .ok_or_else(|| binding_error("invalid_request", "selectors must be an object"))?; + let mut request = LookupRequest::default().options(record_options_request(options)?); + for (name, value) in selectors { + request = request + .selector(name, value.clone()) + .map_err(client_error)?; + } + let etag = parse_etag(etag)?; + conditional_value( + self.inner + .lookup_record(&resource, &lookup, &request, etag.as_ref()) + .await + .map_err(client_error)?, + ) + } + + #[napi] + pub async fn search( + &self, + resource: String, + search: String, + options: Option, + etag: Option, + ) -> Result> { + let request = collection_request(options)?; + let etag = parse_etag(etag)?; + collection_page( + self.inner + .search_records(&resource, &search, &request, etag.as_ref()) + .await + .map_err(client_error)?, + ) + } + + #[napi] + pub async fn continue_search( + &self, + continuation: Value, + etag: Option, + ) -> Result> { + self.continue_collection(continuation, etag, "search").await + } + + #[napi] + pub async fn artifact( + &self, + artifact_identifier: String, + etag: Option, + ) -> Result> { + let etag = parse_etag(etag)?; + Ok(conditional_raw( + self.inner + .artifact(&artifact_identifier, etag.as_ref()) + .await + .map_err(client_error)?, + )) + } + + #[napi] + pub async fn sdmx_data( + &self, + request_value: Value, + etag: Option, + ) -> Result> { + let object = request_object( + Some(request_value), + &[ + "agency", + "resource", + "version", + "key", + "constraints", + "offset", + "limit", + "dimensionAtObservation", + "format", + ], + "SDMX data request must be an object with supported fields", + )?; + let mut request = SdmxDataRequest::new( + required_string( + &object, + "agency", + "invalid_request", + "agency must be a string", + )?, + required_string( + &object, + "resource", + "invalid_request", + "resource must be a string", + )?, + required_string( + &object, + "version", + "invalid_request", + "version must be a string", + )?, + ) + .map_err(client_error)?; + if let Some(value) = request_optional_string(&object, "key", "key must be a string")? { + request = request.keyed(value).map_err(client_error)?; + } + for (name, value) in string_map( + &object, + "constraints", + "constraints must map strings to strings", + )? { + request = request.constraint(name, value).map_err(client_error)?; + } + if let Some(value) = + request_optional_u32(&object, "offset", "offset must be a non-negative integer")? + { + request = request.offset(value); + } + if let Some(value) = + request_optional_u32(&object, "limit", "limit must be a non-negative integer")? + { + request = request.limit(value).map_err(client_error)?; + } + if let Some(value) = request_optional_string( + &object, + "dimensionAtObservation", + "dimensionAtObservation must be a string", + )? { + request = request + .dimension_at_observation(value) + .map_err(client_error)?; + } + request = request.format( + match request_optional_string(&object, "format", "format must be a string")? + .as_deref() + .unwrap_or("json") + { + "json" => SdmxDataFormat::Json, + "csv" => SdmxDataFormat::Csv, + _ => { + return Err(binding_error( + "invalid_request", + "SDMX data format must be json or csv", + )); + } + }, + ); + let etag = parse_etag(etag)?; + Ok(conditional_raw( + self.inner + .sdmx_data(&request, etag.as_ref()) + .await + .map_err(client_error)?, + )) + } + + #[napi] + pub async fn sdmx_structure( + &self, + request_value: Value, + etag: Option, + ) -> Result> { + let object = request_object( + Some(request_value), + &["kind", "agency", "resource", "version"], + "SDMX structure request must be an object with supported fields", + )?; + let kind = + match required_string(&object, "kind", "invalid_request", "kind must be a string")? + .as_str() + { + "dataflow" => SdmxStructureKind::Dataflow, + "datastructure" | "data-structure" => SdmxStructureKind::DataStructure, + _ => { + return Err(binding_error( + "invalid_request", + "SDMX structure kind must be dataflow or datastructure", + )); + } + }; + let request = SdmxStructureRequest::new( + kind, + required_string( + &object, + "agency", + "invalid_request", + "agency must be a string", + )?, + required_string( + &object, + "resource", + "invalid_request", + "resource must be a string", + )?, + required_string( + &object, + "version", + "invalid_request", + "version must be a string", + )?, + ) + .map_err(client_error)?; + let etag = parse_etag(etag)?; + Ok(conditional_raw( + self.inner + .sdmx_structure(&request, etag.as_ref()) + .await + .map_err(client_error)?, + )) + } +} + +impl RelayClient { + async fn continue_collection( + &self, + continuation: Value, + etag: Option, + expected: &'static str, + ) -> Result { + let continuation = collection_continuation(continuation, expected)?; + let etag = parse_etag(etag)?; + collection_page( + self.inner + .continue_collection(&continuation, etag.as_ref()) + .await + .map_err(client_error)?, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn error_envelope(error: NapiError) -> Value { + serde_json::from_str(&error.reason).expect("error reason is a JSON envelope") + } + + #[test] + fn configuration_is_exact_and_value_free() { + let secret = "canary-secret-token"; + let error = config(json!({ + "baseUrl": "https://relay.invalid", + "authorization": {"static": secret, "privateKeyJwt": {}} + })) + .unwrap_err(); + let text = error.reason; + assert!(!text.contains(secret)); + assert_eq!( + serde_json::from_str::(&text).unwrap()["kind"], + "configuration" + ); + } + + #[test] + fn invalid_request_kind_is_not_configuration() { + let error = collection_request(Some(json!({"pageSize": 0}))).unwrap_err(); + assert_eq!(error_envelope(error)["kind"], "invalid_request"); + } + + #[test] + fn continuation_route_must_match_its_consumer() { + let error = collection_continuation( + json!({ + "route": {"kind": "search", "resource": "people", "search": "by-name"}, + "cursor": "opaque", + "format": "json" + }), + "records", + ) + .unwrap_err(); + assert_eq!(error_envelope(error)["kind"], "invalid_request"); + } + + #[test] + fn resource_continuation_uses_the_closed_core_projection() { + let error = resource_continuation(json!({ + "cursor": "opaque", + "pageSize": 100 + })) + .unwrap_err(); + assert_eq!(error_envelope(error)["kind"], "invalid_request"); + } + + #[test] + fn private_key_jwt_rejects_unknown_oauth_settings_without_exposing_key() { + let secret = "canary-private-key"; + let result = authorization_provider(&json!({ + "privateKeyJwt": { + "tokenEndpoint": "https://issuer.invalid/token", + "clientId": "client", + "clientKey": {"kty": "OKP", "d": secret}, + "scope": "not-supported" + } + })); + let error = match result { + Ok(_) => panic!("unsupported private-key JWT configuration was accepted"), + Err(error) => error, + }; + assert!(!error.reason.contains(secret)); + assert_eq!(error_envelope(error)["kind"], "configuration"); + } +} diff --git a/crates/registry-relay-client-py/.gitignore b/crates/registry-relay-client-py/.gitignore new file mode 100644 index 000000000..f71aa609a --- /dev/null +++ b/crates/registry-relay-client-py/.gitignore @@ -0,0 +1,4 @@ +*.so +*.dylib +*.pyd +__pycache__/ diff --git a/crates/registry-relay-client-py/Cargo.toml b/crates/registry-relay-client-py/Cargo.toml new file mode 100644 index 000000000..1df6d2251 --- /dev/null +++ b/crates/registry-relay-client-py/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "registry-relay-client-py" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Python binding for the Registry Relay V2 client, via PyO3." +readme = "README.md" +repository.workspace = true +publish = false + +[lib] +name = "registry_relay_client" +crate-type = ["cdylib", "rlib"] + +[lints] +workspace = true + +[features] +extension-module = ["pyo3/extension-module"] + +[dependencies] +pyo3.workspace = true +registry-platform-crypto.workspace = true +relay-client-sdk = { package = "registry-relay-client", path = "../registry-relay-client", version = "0.19.0" } +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +url.workspace = true + +[build-dependencies] +pyo3-build-config.workspace = true + +[dev-dependencies] +pyo3 = { workspace = true, features = ["auto-initialize"] } +wiremock.workspace = true diff --git a/crates/registry-relay-client-py/LICENSE b/crates/registry-relay-client-py/LICENSE new file mode 100644 index 000000000..0421f3c2d --- /dev/null +++ b/crates/registry-relay-client-py/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Jeremi Joslin + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/registry-relay-client-py/README.md b/crates/registry-relay-client-py/README.md new file mode 100644 index 000000000..0f6eeef6e --- /dev/null +++ b/crates/registry-relay-client-py/README.md @@ -0,0 +1,57 @@ +# Registry Relay client for Python + +This package is the thin synchronous Python binding for +`registry-relay-client`. It performs one bounded SDK exchange per method and +does not implement HTTP routing, authentication, retries, pagination, or Relay +Problem Details itself. + +```python +from registry_relay_client import RelayClient + +client = RelayClient(base_url="https://relay.example") +result = client.service_metadata() +if result["kind"] == "complete": + print(result["value"]["name"]) +``` + +An optional static bearer is supplied as a string. Private-key JWT uses an +exactly-one-key wrapper: + +```python +client = RelayClient( + base_url="https://relay.example/prefix", + authorization={ + "private_key_jwt": { + "token_endpoint": "https://issuer.example/oauth/token", + "client_id": "relying-party", + "client_key": private_jwk, + } + }, +) +``` + +Top-level `trusted_root_certificates` configures the Relay deployment +connection. A token endpoint using a private CA needs its own byte-valued +`trusted_root_certificates` inside `private_key_jwt`; the two trust inputs are +deliberately independent. + +The built-in private-key-JWT flow sends only `grant_type`, +`client_assertion_type`, and `client_assertion`. It does not send `scope`, +`resource`, a body `client_id`, or deployment-defined form members. When an +issuer requires any of those fields, acquire a short-lived bearer separately +and pass it as the static `authorization` string. + +Every method is blocking and releases the Python GIL while the private +current-thread Tokio runtime waits for I/O. Conditional methods return a plain +mapping discriminated by `kind`: either `complete` with `value`, `trace_id`, +and optional `etag`, or `not_modified` with `trace_id` and `etag`. Page results +also carry a plain `continuation`, which only the matching continuation method +accepts. Raw OpenAPI, artifact, and SDMX bodies are returned as `bytes`. + +Build and test from the workspace root: + +```sh +cargo build --locked -p registry-relay-client-py --lib \ + --features registry-relay-client-py/extension-module +python3 -m unittest discover -s crates/registry-relay-client-py/tests/python -v +``` diff --git a/crates/registry-relay-client-py/build.rs b/crates/registry-relay-client-py/build.rs new file mode 100644 index 000000000..58605dd92 --- /dev/null +++ b/crates/registry-relay-client-py/build.rs @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: Apache-2.0 + +fn main() { + if std::env::var_os("CARGO_FEATURE_EXTENSION_MODULE").is_some() { + pyo3_build_config::add_extension_module_link_args(); + } else { + pyo3_build_config::add_libpython_rpath_link_args(); + } +} diff --git a/crates/registry-relay-client-py/pyproject.toml b/crates/registry-relay-client-py/pyproject.toml new file mode 100644 index 000000000..7c98f4868 --- /dev/null +++ b/crates/registry-relay-client-py/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "registry-relay-client" +version = "0.19.0" +description = "Python binding for the Registry Relay V2 client, via PyO3." +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.10" + +[tool.maturin] +python-source = "python" +module-name = "registry_relay_client" +features = ["extension-module"] diff --git a/crates/registry-relay-client-py/python/registry_relay_client/__init__.py b/crates/registry-relay-client-py/python/registry_relay_client/__init__.py new file mode 100644 index 000000000..2cd957b1f --- /dev/null +++ b/crates/registry-relay-client-py/python/registry_relay_client/__init__.py @@ -0,0 +1,5 @@ +"""Synchronous Python binding for the Registry Relay V2 client.""" + +from .registry_relay_client import * # noqa: F401,F403 + +globals().pop("registry_relay_client", None) diff --git a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi new file mode 100644 index 000000000..4e8302cb5 --- /dev/null +++ b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi @@ -0,0 +1,228 @@ +"""Types for the synchronous Registry Relay V2 client binding.""" + +from typing import Any, Literal, Mapping, Optional, Sequence, TypedDict, Union + +RecordFormat = Literal["json", "json-ld", "geojson", "json-fg"] +SdmxDataFormat = Literal["json", "csv"] +SdmxStructureKind = Literal["dataflow", "datastructure"] +Selector = Union[str, int, bool] + +class _PrivateKeyJwtRequired(TypedDict): + token_endpoint: str + client_id: str + client_key: Mapping[str, Any] + + +class PrivateKeyJwtConfig(_PrivateKeyJwtRequired, total=False): + audience: Optional[str] + assertion_lifetime_seconds: Optional[int] + refresh_margin_seconds: Optional[int] + request_timeout_seconds: Optional[float] + connect_timeout_seconds: Optional[float] + user_agent: Optional[str] + trusted_root_certificates: bytes + + +PrivateKeyJwtAuthorization = TypedDict( + "PrivateKeyJwtAuthorization", {"private_key_jwt": PrivateKeyJwtConfig} +) +RecordsRoute = TypedDict( + "RecordsRoute", {"kind": Literal["records"], "resource": str} +) +SearchRoute = TypedDict( + "SearchRoute", + {"kind": Literal["search"], "resource": str, "search": str}, +) + + +class ResourceContinuation(TypedDict): + cursor: str + + +class _CollectionContinuationRequired(TypedDict): + route: Union[RecordsRoute, SearchRoute] + cursor: str + format: Literal["json", "json-ld", "geojson-rfc7946", "json-fg"] + + +class CollectionContinuation(_CollectionContinuationRequired, total=False): + accessProfile: str + + +CompleteOutcome = TypedDict( + "CompleteOutcome", + { + "kind": Literal["complete"], + "value": Any, + "trace_id": str, + "etag": Optional[str], + }, +) +RawCompleteOutcome = TypedDict( + "RawCompleteOutcome", + { + "kind": Literal["complete"], + "body": bytes, + "media_type": str, + "trace_id": str, + "etag": Optional[str], + }, +) +ResourcePageCompleteOutcome = TypedDict( + "ResourcePageCompleteOutcome", + { + "kind": Literal["complete"], + "value": Any, + "continuation": Optional[ResourceContinuation], + "trace_id": str, + "etag": Optional[str], + }, +) +CollectionPageCompleteOutcome = TypedDict( + "CollectionPageCompleteOutcome", + { + "kind": Literal["complete"], + "value": Any, + "continuation": Optional[CollectionContinuation], + "trace_id": str, + "etag": Optional[str], + }, +) +NotModifiedOutcome = TypedDict( + "NotModifiedOutcome", + {"kind": Literal["not_modified"], "etag": str, "trace_id": str}, +) +Outcome = Union[CompleteOutcome, NotModifiedOutcome] +RawOutcome = Union[RawCompleteOutcome, NotModifiedOutcome] +ResourcePageOutcome = Union[ResourcePageCompleteOutcome, NotModifiedOutcome] +CollectionPageOutcome = Union[CollectionPageCompleteOutcome, NotModifiedOutcome] + + +class RelayClientError(Exception): + """A fixed, value-free client failure. + + `kind` is always present. Other fields are `None` when the corresponding + Rust failure carries no such fact. No attribute contains a credential, + response body, header value, selector, filter, or route value. + """ + + kind: str + code: Optional[str] + status: Optional[int] + trace_id: Optional[str] + retry_after_seconds: Optional[int] + transport_kind: Optional[str] + token_kind: Optional[str] + + +class RelayClient: + def __init__( + self, + base_url: str, + authorization: Optional[ + Union[str, PrivateKeyJwtAuthorization] + ] = ..., + request_timeout_seconds: Optional[float] = ..., + connect_timeout_seconds: Optional[float] = ..., + user_agent: Optional[str] = ..., + max_response_bytes: Optional[int] = ..., + trusted_root_certificates: Optional[bytes] = ..., + ) -> None: ... + + def health(self) -> CompleteOutcome: ... + def ready(self) -> CompleteOutcome: ... + def openapi(self, etag: Optional[str] = ...) -> RawOutcome: ... + def service_metadata(self, etag: Optional[str] = ...) -> Outcome: ... + def resources( + self, + page_size: Optional[int] = ..., + etag: Optional[str] = ..., + ) -> ResourcePageOutcome: ... + def continue_resources( + self, continuation: ResourceContinuation, etag: Optional[str] = ... + ) -> ResourcePageOutcome: ... + def resource( + self, resource: str, etag: Optional[str] = ... + ) -> Outcome: ... + def list_records( + self, + resource: str, + *, + page_size: Optional[int] = ..., + fields: Optional[Sequence[str]] = ..., + access_profile: Optional[str] = ..., + format: RecordFormat = ..., + filters: Optional[Mapping[str, str]] = ..., + bbox: Optional[Sequence[float]] = ..., + etag: Optional[str] = ..., + ) -> CollectionPageOutcome: ... + def continue_list_records( + self, + continuation: CollectionContinuation, + etag: Optional[str] = ..., + ) -> CollectionPageOutcome: ... + def read_record( + self, + resource: str, + record_identifier: str, + *, + fields: Optional[Sequence[str]] = ..., + access_profile: Optional[str] = ..., + format: RecordFormat = ..., + etag: Optional[str] = ..., + ) -> Outcome: ... + def lookup( + self, + resource: str, + lookup: str, + selectors: Mapping[str, Selector], + *, + fields: Optional[Sequence[str]] = ..., + access_profile: Optional[str] = ..., + format: RecordFormat = ..., + etag: Optional[str] = ..., + ) -> Outcome: ... + def search( + self, + resource: str, + search: str, + *, + page_size: Optional[int] = ..., + fields: Optional[Sequence[str]] = ..., + access_profile: Optional[str] = ..., + format: RecordFormat = ..., + filters: Optional[Mapping[str, str]] = ..., + bbox: Optional[Sequence[float]] = ..., + etag: Optional[str] = ..., + ) -> CollectionPageOutcome: ... + def continue_search( + self, + continuation: CollectionContinuation, + etag: Optional[str] = ..., + ) -> CollectionPageOutcome: ... + def artifact( + self, artifact_identifier: str, etag: Optional[str] = ... + ) -> RawOutcome: ... + def sdmx_data( + self, + agency: str, + resource: str, + version: str, + *, + key: Optional[str] = ..., + constraints: Optional[Mapping[str, str]] = ..., + offset: Optional[int] = ..., + limit: Optional[int] = ..., + dimension_at_observation: Optional[str] = ..., + format: SdmxDataFormat = ..., + etag: Optional[str] = ..., + ) -> RawOutcome: ... + def sdmx_structure( + self, + kind: SdmxStructureKind, + agency: str, + resource: str, + version: str, + *, + etag: Optional[str] = ..., + ) -> RawOutcome: ... diff --git a/crates/registry-relay-client-py/python/registry_relay_client/py.typed b/crates/registry-relay-client-py/python/registry_relay_client/py.typed new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/crates/registry-relay-client-py/python/registry_relay_client/py.typed @@ -0,0 +1 @@ + diff --git a/crates/registry-relay-client-py/src/convert.rs b/crates/registry-relay-client-py/src/convert.rs new file mode 100644 index 000000000..1f74086dc --- /dev/null +++ b/crates/registry-relay-client-py/src/convert.rs @@ -0,0 +1,699 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use pyo3::{ + prelude::*, + types::{PyBool, PyBytes, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple}, + IntoPyObjectExt, +}; +use registry_platform_crypto::PrivateJwk; +use relay_client_sdk::{ + PrivateKeyJwt, PrivateKeyJwtConfig, ProtocolFailure, RelayClientConfig, RelayClientError, + StaticToken, TokenError, TokenProvider, MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES, +}; +use serde::Serialize; +use serde_json::{Map, Value}; +use url::Url; + +const MAX_JSON_DEPTH: usize = 128; +const MAX_JSON_NODES: usize = 100_000; +const MAX_JSON_STRING_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ConversionError { + message: String, +} + +impl ConversionError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + pub fn message(&self) -> &str { + &self.message + } +} + +struct ConversionBudget { + nodes: usize, + string_bytes: usize, + active_containers: HashSet, +} + +impl ConversionBudget { + fn new() -> Self { + Self { + nodes: 0, + string_bytes: 0, + active_containers: HashSet::new(), + } + } + + fn visit(&mut self) -> Result<(), ConversionError> { + self.nodes += 1; + if self.nodes > MAX_JSON_NODES { + return Err(ConversionError::new( + "the Python object graph exceeds the conversion size bound", + )); + } + Ok(()) + } + + fn count_string(&mut self, value: &str) -> Result<(), ConversionError> { + self.string_bytes = self.string_bytes.saturating_add(value.len()); + if self.string_bytes > MAX_JSON_STRING_BYTES { + return Err(ConversionError::new( + "the Python object graph exceeds the conversion text bound", + )); + } + Ok(()) + } + + fn enter_container(&mut self, value: &Bound<'_, PyAny>) -> Result { + let identity = value.as_ptr() as usize; + if !self.active_containers.insert(identity) { + return Err(ConversionError::new( + "a cyclic Python object graph cannot be converted", + )); + } + Ok(identity) + } + + fn leave_container(&mut self, identity: usize) { + self.active_containers.remove(&identity); + } +} + +/// Convert only ordinary JSON-shaped Python values, under explicit graph, +/// depth, and text bounds. Container identity tracking rejects cycles before +/// they can consume the depth budget. +pub fn python_to_json(value: &Bound<'_, PyAny>) -> Result { + python_to_json_at_depth(value, 1, &mut ConversionBudget::new()) +} + +/// Convert the authorization shape without teaching the general JSON bridge +/// about bytes. The one byte-valued field is kept outside the JSON graph and +/// handed only to the shared private-key-JWT HTTP client builder. +pub fn authorization_from_python( + value: Option<&Bound<'_, PyAny>>, +) -> Result<(Value, Option>), ConversionError> { + const PRIVATE_KEY_JWT_FIELDS: &[&str] = &[ + "token_endpoint", + "client_id", + "client_key", + "audience", + "assertion_lifetime_seconds", + "refresh_margin_seconds", + "request_timeout_seconds", + "connect_timeout_seconds", + "user_agent", + "trusted_root_certificates", + ]; + let Some(value) = value else { + return Ok((Value::Null, None)); + }; + let Ok(outer) = value.cast::() else { + return python_to_json(value).map(|value| (value, None)); + }; + if outer.len() != 1 { + return python_to_json(value).map(|value| (value, None)); + } + let Some(private_key_jwt) = outer + .get_item("private_key_jwt") + .map_err(|_| ConversionError::new("authorization could not be read"))? + else { + return python_to_json(value).map(|value| (value, None)); + }; + let Ok(private_key_jwt) = private_key_jwt.cast::() else { + return python_to_json(value).map(|value| (value, None)); + }; + + let mut budget = ConversionBudget::new(); + budget.visit()?; + let outer_identity = budget.enter_container(value)?; + budget.visit()?; + let config_identity = budget.enter_container(private_key_jwt.as_any())?; + for (key, _) in private_key_jwt.iter() { + let key = key + .cast::() + .map_err(|_| ConversionError::new("a mapping key must be a string"))? + .to_str() + .map_err(|_| ConversionError::new("a mapping key must be valid Unicode"))?; + budget.count_string(key)?; + if !PRIVATE_KEY_JWT_FIELDS.contains(&key) { + return Err(ConversionError::new( + "authorization[\"private_key_jwt\"] carries an unsupported field", + )); + } + } + let Some(trusted_roots) = private_key_jwt + .get_item("trusted_root_certificates") + .map_err(|_| ConversionError::new("authorization could not be read"))? + else { + return python_to_json(value).map(|value| (value, None)); + }; + let trusted_roots = trusted_roots.cast::().map_err(|_| { + ConversionError::new( + "authorization[\"private_key_jwt\"][\"trusted_root_certificates\"] must be bytes", + ) + })?; + if trusted_roots.as_bytes().len() > MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES { + return Err(ConversionError::new( + "authorization[\"private_key_jwt\"][\"trusted_root_certificates\"] exceeds the accepted byte bound", + )); + } + + let mut config = Map::new(); + for (key, value) in private_key_jwt.iter() { + let key = key + .cast::() + .map_err(|_| ConversionError::new("a mapping key must be a string"))? + .to_str() + .map_err(|_| ConversionError::new("a mapping key must be valid Unicode"))?; + if key != "trusted_root_certificates" { + config.insert( + key.to_owned(), + python_to_json_at_depth(&value, 3, &mut budget)?, + ); + } + } + budget.leave_container(config_identity); + budget.leave_container(outer_identity); + let mut authorization = Map::new(); + authorization.insert("private_key_jwt".into(), Value::Object(config)); + Ok(( + Value::Object(authorization), + Some(trusted_roots.as_bytes().to_vec()), + )) +} + +fn python_to_json_at_depth( + value: &Bound<'_, PyAny>, + depth: usize, + budget: &mut ConversionBudget, +) -> Result { + if depth > MAX_JSON_DEPTH { + return Err(ConversionError::new(format!( + "a Python value nested more than {MAX_JSON_DEPTH} levels deep cannot be converted" + ))); + } + budget.visit()?; + if value.is_none() { + return Ok(Value::Null); + } + if let Ok(flag) = value.cast::() { + return Ok(Value::Bool(flag.is_true())); + } + if let Ok(integer) = value.cast::() { + let integer: i64 = integer + .extract() + .map_err(|_| ConversionError::new("an integer value must fit in 64 bits"))?; + return Ok(Value::from(integer)); + } + if let Ok(float) = value.cast::() { + let float: f64 = float + .extract() + .map_err(|_| ConversionError::new("a floating-point value could not be read"))?; + return serde_json::Number::from_f64(float) + .map(Value::Number) + .ok_or_else(|| ConversionError::new("a floating-point value must be finite")); + } + if let Ok(text) = value.cast::() { + let text = text + .to_str() + .map_err(|_| ConversionError::new("a string value must be valid Unicode"))?; + budget.count_string(text)?; + return Ok(Value::String(text.to_owned())); + } + if let Ok(list) = value.cast::() { + let identity = budget.enter_container(value)?; + let result = list + .iter() + .map(|item| python_to_json_at_depth(&item, depth + 1, budget)) + .collect::, _>>() + .map(Value::Array); + budget.leave_container(identity); + return result; + } + if let Ok(tuple) = value.cast::() { + let identity = budget.enter_container(value)?; + let result = tuple + .iter() + .map(|item| python_to_json_at_depth(&item, depth + 1, budget)) + .collect::, _>>() + .map(Value::Array); + budget.leave_container(identity); + return result; + } + if let Ok(dict) = value.cast::() { + let identity = budget.enter_container(value)?; + let mut object = Map::new(); + let result = (|| { + for (key, value) in dict.iter() { + let key = key + .cast::() + .map_err(|_| ConversionError::new("a mapping key must be a string"))? + .to_str() + .map_err(|_| ConversionError::new("a mapping key must be valid Unicode"))?; + budget.count_string(key)?; + object.insert( + key.to_owned(), + python_to_json_at_depth(&value, depth + 1, budget)?, + ); + } + Ok(Value::Object(object)) + })(); + budget.leave_container(identity); + return result; + } + Err(ConversionError::new( + "a value of this Python type cannot be converted", + )) +} + +pub fn json_to_python<'py>(py: Python<'py>, value: &Value) -> PyResult> { + match value { + Value::Null => Ok(py.None().into_bound(py)), + Value::Bool(value) => (*value).into_bound_py_any(py), + Value::Number(value) => { + if let Some(value) = value.as_i64() { + value.into_bound_py_any(py) + } else if let Some(value) = value.as_u64() { + value.into_bound_py_any(py) + } else if let Some(value) = value.as_f64() { + value.into_bound_py_any(py) + } else { + Err(pyo3::exceptions::PyValueError::new_err( + "a JSON number could not be represented in Python", + )) + } + } + Value::String(value) => value.as_str().into_bound_py_any(py), + Value::Array(values) => { + let values = values + .iter() + .map(|value| json_to_python(py, value)) + .collect::>>()?; + Ok(PyList::new(py, values)?.into_any()) + } + Value::Object(values) => { + let result = PyDict::new(py); + for (key, value) in values { + result.set_item(key, json_to_python(py, value)?)?; + } + Ok(result.into_any()) + } + } +} + +pub fn serialize_to_python<'py>( + py: Python<'py>, + value: &impl Serialize, +) -> PyResult> { + let value = serde_json::to_value(value).map_err(|_| { + pyo3::exceptions::PyValueError::new_err("an SDK result could not be serialized") + })?; + json_to_python(py, &value) +} + +fn required_object<'a>( + value: &'a Value, + what: &str, +) -> Result<&'a Map, ConversionError> { + value + .as_object() + .ok_or_else(|| ConversionError::new(format!("{what} must be an object"))) +} + +fn require_only_fields( + value: &Map, + allowed: &[&str], + what: &str, +) -> Result<(), ConversionError> { + if value.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(ConversionError::new(format!( + "{what} carries an unsupported field" + ))); + } + Ok(()) +} + +fn required_string( + value: &Map, + field: &str, + what: &str, +) -> Result { + value + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| ConversionError::new(format!("{what}[\"{field}\"] must be a string"))) +} + +fn optional_string( + value: &Map, + field: &str, + what: &str, +) -> Result, ConversionError> { + match value.get(field) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(value)) => Ok(Some(value.clone())), + Some(_) => Err(ConversionError::new(format!( + "{what}[\"{field}\"] must be a string" + ))), + } +} + +fn optional_i64( + value: &Map, + field: &str, + what: &str, +) -> Result, ConversionError> { + match value.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value.as_i64().map(Some).ok_or_else(|| { + ConversionError::new(format!("{what}[\"{field}\"] must be a 64-bit integer")) + }), + } +} + +fn optional_f64( + value: &Map, + field: &str, + what: &str, +) -> Result, ConversionError> { + match value.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value + .as_f64() + .map(Some) + .ok_or_else(|| ConversionError::new(format!("{what}[\"{field}\"] must be a number"))), + } +} + +fn duration(seconds: f64, what: &str) -> Result { + Duration::try_from_secs_f64(seconds).map_err(|_| { + ConversionError::new(format!( + "{what} must be a finite non-negative number of seconds" + )) + }) +} + +fn private_key_jwt( + value: &Value, + trusted_root_certificates: Option>, +) -> Result { + const WHAT: &str = "authorization[\"private_key_jwt\"]"; + let value = required_object(value, WHAT)?; + require_only_fields( + value, + &[ + "token_endpoint", + "client_id", + "client_key", + "audience", + "assertion_lifetime_seconds", + "refresh_margin_seconds", + "request_timeout_seconds", + "connect_timeout_seconds", + "user_agent", + ], + WHAT, + )?; + let token_endpoint = Url::parse(&required_string(value, "token_endpoint", WHAT)?) + .map_err(|_| ConversionError::new("the private-key JWT token endpoint is invalid"))?; + let client_id = required_string(value, "client_id", WHAT)?; + let key = value + .get("client_key") + .ok_or_else(|| ConversionError::new("the private-key JWT client key is required"))?; + let key = serde_json::to_string(key) + .map_err(|_| ConversionError::new("the private-key JWT client key is invalid"))?; + let key = PrivateJwk::parse(&key) + .map_err(|_| ConversionError::new("the private-key JWT client key is invalid"))?; + + let mut config = PrivateKeyJwtConfig::new(token_endpoint, client_id, key); + if let Some(value) = optional_string(value, "audience", WHAT)? { + config = config.with_audience(value); + } + if let Some(value) = optional_i64(value, "assertion_lifetime_seconds", WHAT)? { + config = config.with_assertion_lifetime_seconds(value); + } + 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, "connect_timeout_seconds", WHAT)? { + config = config.with_connect_timeout(duration(value, "private-key JWT connect timeout")?); + } + if let Some(value) = optional_string(value, "user_agent", WHAT)? { + config = config.with_user_agent(value); + } + if let Some(value) = trusted_root_certificates { + config = config.with_trusted_root_certificates(value); + } + PrivateKeyJwt::new(config).map_err(ConfigError::Token) +} + +fn authorization_provider( + authorization: &Value, + private_key_jwt_trusted_root_certificates: Option>, +) -> Result>, ConfigError> { + match authorization { + Value::Null => Ok(None), + Value::String(value) => Ok(Some(Arc::new( + StaticToken::new(value).map_err(ConfigError::Token)?, + ))), + Value::Object(value) if value.len() == 1 => { + let value = value.get("private_key_jwt").ok_or_else(|| { + ConversionError::new( + "authorization must be a string or an object with exactly private_key_jwt", + ) + })?; + Ok(Some(Arc::new(private_key_jwt( + value, + private_key_jwt_trusted_root_certificates, + )?))) + } + _ => Err(ConversionError::new( + "authorization must be null, a static string, or an object with exactly private_key_jwt", + ) + .into()), + } +} + +#[derive(Debug)] +pub enum ConfigError { + Shape(ConversionError), + Token(TokenError), +} + +impl From for ConfigError { + fn from(value: ConversionError) -> Self { + Self::Shape(value) + } +} + +#[allow(clippy::too_many_arguments)] +pub fn config_from_parts( + base_url: &str, + authorization: &Value, + private_key_jwt_trusted_root_certificates: Option>, + request_timeout_seconds: Option, + connect_timeout_seconds: Option, + user_agent: Option, + max_response_bytes: Option, + trusted_root_certificates: Option>, +) -> Result { + let base_url = + Url::parse(base_url).map_err(|_| ConversionError::new("base_url must be a valid URL"))?; + let mut config = RelayClientConfig::new(base_url); + if let Some(provider) = + authorization_provider(authorization, private_key_jwt_trusted_root_certificates)? + { + 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) = connect_timeout_seconds { + config = config.with_connect_timeout(duration(value, "connect_timeout_seconds")?); + } + 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) = trusted_root_certificates { + config = config.with_trusted_root_certificates(value); + } + Ok(config) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MappedError { + pub kind: &'static str, + pub message: String, + pub code: Option, + pub status: Option, + pub trace_id: Option, + pub retry_after_seconds: Option, + pub transport_kind: Option<&'static str>, + pub token_kind: Option<&'static str>, +} + +impl MappedError { + pub fn binding(kind: &'static str, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + code: None, + status: None, + trace_id: None, + retry_after_seconds: None, + transport_kind: None, + token_kind: None, + } + } +} + +fn protocol_code(value: ProtocolFailure) -> &'static str { + match value { + ProtocolFailure::HeaderBounds => "header_bounds", + ProtocolFailure::TraceContext => "trace_context", + ProtocolFailure::MediaType => "media_type", + ProtocolFailure::Body => "body", + ProtocolFailure::Problem => "problem", + ProtocolFailure::EntityTag => "entity_tag", + ProtocolFailure::NotModifiedBody => "not_modified_body", + ProtocolFailure::Status => "status", + _ => "protocol", + } +} + +pub fn map_client_error(error: &RelayClientError) -> MappedError { + let mut mapped = MappedError::binding( + match error { + RelayClientError::Configuration { .. } => "configuration", + RelayClientError::InvalidRequest { .. } => "invalid_request", + RelayClientError::Token(_) => "token", + RelayClientError::Transport { .. } => "transport", + RelayClientError::Problem { .. } => "problem", + RelayClientError::Protocol { .. } => "protocol", + _ => "client", + }, + error.to_string(), + ); + match error { + RelayClientError::Token(error) => { + mapped.token_kind = Some(error.kind()); + match error { + TokenError::Transport { kind } => mapped.transport_kind = Some(kind.kind()), + TokenError::Refused { code } => mapped.code = Some(code.as_str().to_owned()), + TokenError::Protocol { status } => mapped.status = Some(*status), + _ => {} + } + } + RelayClientError::Transport { kind } => mapped.transport_kind = Some(kind.kind()), + RelayClientError::Problem { + status, + code, + trace_id, + retry_after_seconds, + } => { + mapped.status = Some(*status); + mapped.code = Some(code.code().to_owned()); + mapped.trace_id = Some(trace_id.as_str().to_owned()); + mapped.retry_after_seconds = *retry_after_seconds; + } + RelayClientError::Protocol { + status, + failure, + trace_id, + } => { + mapped.status = Some(*status); + mapped.code = Some(protocol_code(*failure).to_owned()); + mapped.trace_id = trace_id.as_ref().map(|value| value.as_str().to_owned()); + } + _ => {} + } + mapped +} + +pub fn map_config_error(error: &ConfigError) -> MappedError { + match error { + ConfigError::Shape(error) => MappedError::binding("configuration", error.message()), + ConfigError::Token(error) => map_client_error(&RelayClientError::Token(*error)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn python_conversion_rejects_cycles_and_non_finite_numbers() { + Python::attach(|py| { + let list = PyList::empty(py); + list.append(&list).unwrap(); + assert!(python_to_json(list.as_any()) + .unwrap_err() + .message() + .contains("cyclic")); + let nan = f64::NAN.into_bound_py_any(py).unwrap(); + assert!(python_to_json(&nan).is_err()); + let bytes = PyBytes::new(py, b"bytes stay outside the JSON bridge"); + assert!(python_to_json(bytes.as_any()).is_err()); + }); + } + + #[test] + fn optional_authorization_accepts_none_and_static_tokens_without_rendering_them() { + assert!(config_from_parts( + "http://127.0.0.1:8080/prefix", + &Value::Null, + None, + None, + None, + None, + None, + None, + ) + .is_ok()); + let token = "canary-static-token"; + let config = config_from_parts( + "http://127.0.0.1:8080/prefix", + &Value::String(token.to_owned()), + None, + None, + None, + None, + None, + None, + ) + .unwrap(); + assert!(!format!("{config:?}").contains(token)); + } + + #[test] + fn authorization_object_is_exactly_one_private_key_jwt_member() { + let error = config_from_parts( + "http://127.0.0.1:8080", + &serde_json::json!({"private_key_jwt": {}, "static": "canary"}), + None, + None, + None, + None, + None, + None, + ) + .unwrap_err(); + assert!(matches!(error, ConfigError::Shape(_))); + assert!(!format!("{error:?}").contains("canary")); + } +} diff --git a/crates/registry-relay-client-py/src/lib.rs b/crates/registry-relay-client-py/src/lib.rs new file mode 100644 index 000000000..57f3a32c2 --- /dev/null +++ b/crates/registry-relay-client-py/src/lib.rs @@ -0,0 +1,857 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Synchronous Python binding for the canonical Registry Relay V2 client. + +use std::collections::BTreeMap; + +use pyo3::{ + exceptions::{PyException, PyRuntimeError}, + prelude::*, + types::{PyBytes, PyDict}, +}; +use relay_client_sdk::{ + BoundingBox, CollectionContinuation, CollectionContinuationProjection, CollectionPage, + CollectionRequest, CollectionRouteProjection, Complete, Conditional, LookupRequest, + NotModified, RawDocument, RecordFormat, RecordOptions, RelayClient as RustClient, + RelayClientError as RustClientError, ResourceContinuation, ResourceContinuationProjection, + ResourceListRequest, ResourcePage, ResponseMetadata, SdmxDataFormat, SdmxDataRequest, + SdmxStructureKind, SdmxStructureRequest, StrongEtag, +}; +use serde::Serialize; +use serde_json::Value; + +mod convert; + +use convert::{ + authorization_from_python, config_from_parts, map_client_error, map_config_error, + python_to_json, serialize_to_python, ConversionError, MappedError, +}; + +pyo3::create_exception!( + registry_relay_client, + RelayClientError, + PyException, + "A stable, value-free Registry Relay client failure. Inspect kind and the optional structured attributes." +); + +fn to_py_err(py: Python<'_>, mapped: &MappedError) -> PyErr { + let error = RelayClientError::new_err(mapped.message.clone()); + let instance = error.value(py); + macro_rules! set_attr { + ($name:literal, $value:expr) => { + instance + .setattr($name, $value) + .expect("a fresh exception accepts its stable attributes") + }; + } + set_attr!("kind", mapped.kind); + set_attr!("code", mapped.code.as_deref()); + set_attr!("status", mapped.status); + set_attr!("trace_id", mapped.trace_id.as_deref()); + set_attr!("retry_after_seconds", mapped.retry_after_seconds); + set_attr!("transport_kind", mapped.transport_kind); + set_attr!("token_kind", mapped.token_kind); + error +} + +fn conversion_error(py: Python<'_>, kind: &'static str, error: &ConversionError) -> PyErr { + to_py_err(py, &MappedError::binding(kind, error.message())) +} + +fn sdk_error(py: Python<'_>, error: &RustClientError) -> PyErr { + to_py_err(py, &map_client_error(error)) +} + +fn parse_etag(py: Python<'_>, value: Option<&str>) -> PyResult> { + value + .map(|value| { + StrongEtag::parse(value).map_err(|_| { + to_py_err( + py, + &MappedError::binding( + "invalid_request", + "etag must be a strong quoted SHA-256 entity tag", + ), + ) + }) + }) + .transpose() +} + +fn record_format(py: Python<'_>, value: &str) -> PyResult { + match value { + "json" => Ok(RecordFormat::Json), + "json-ld" => Ok(RecordFormat::JsonLd), + "geojson" => Ok(RecordFormat::GeoJsonRfc7946), + "json-fg" => Ok(RecordFormat::JsonFg), + _ => Err(to_py_err( + py, + &MappedError::binding( + "invalid_request", + "format must be json, json-ld, geojson, or json-fg", + ), + )), + } +} + +fn record_options( + py: Python<'_>, + fields: Option>, + access_profile: Option, + format: &str, +) -> PyResult { + let mut options = RecordOptions::default().format(record_format(py, format)?); + if let Some(fields) = fields { + options = options + .fields(fields) + .map_err(|error| sdk_error(py, &error))?; + } + if let Some(access_profile) = access_profile { + options = options + .access_profile(access_profile) + .map_err(|error| sdk_error(py, &error))?; + } + Ok(options) +} + +fn string_map( + py: Python<'_>, + value: Option<&Bound<'_, PyAny>>, + what: &str, +) -> PyResult> { + let Some(value) = value else { + return Ok(BTreeMap::new()); + }; + let value = + python_to_json(value).map_err(|error| conversion_error(py, "invalid_request", &error))?; + let Value::Object(value) = value else { + return Err(to_py_err( + py, + &MappedError::binding("invalid_request", format!("{what} must be a mapping")), + )); + }; + value + .into_iter() + .map(|(name, value)| match value { + Value::String(value) => Ok((name, value)), + _ => Err(to_py_err( + py, + &MappedError::binding( + "invalid_request", + format!("every {what} value must be a string"), + ), + )), + }) + .collect() +} + +fn bounding_box(py: Python<'_>, value: Option<&Bound<'_, PyAny>>) -> PyResult> { + let Some(value) = value else { + return Ok(None); + }; + let value = + python_to_json(value).map_err(|error| conversion_error(py, "invalid_request", &error))?; + let Value::Array(values) = value else { + return Err(to_py_err( + py, + &MappedError::binding("invalid_request", "bbox must be a four-number sequence"), + )); + }; + let numbers = values + .iter() + .map(Value::as_f64) + .collect::>>() + .filter(|values| values.len() == 4) + .ok_or_else(|| { + to_py_err( + py, + &MappedError::binding("invalid_request", "bbox must be a four-number sequence"), + ) + })?; + BoundingBox::new(numbers[0], numbers[1], numbers[2], numbers[3]) + .map(Some) + .map_err(|error| sdk_error(py, &error)) +} + +#[allow(clippy::too_many_arguments)] +fn collection_request( + py: Python<'_>, + page_size: Option, + fields: Option>, + access_profile: Option, + format: &str, + filters: Option<&Bound<'_, PyAny>>, + bbox: Option<&Bound<'_, PyAny>>, +) -> PyResult { + let options = record_options(py, fields, access_profile, format)?; + let mut request = CollectionRequest::default().options(options); + if let Some(page_size) = page_size { + request = request + .page_size(page_size) + .map_err(|error| sdk_error(py, &error))?; + } + for (name, value) in string_map(py, filters, "filter")? { + request = request + .filter(name, value) + .map_err(|error| sdk_error(py, &error))?; + } + if let Some(bbox) = bounding_box(py, bbox)? { + request = request.bbox(bbox); + } + Ok(request) +} + +fn lookup_request( + py: Python<'_>, + selectors: &Bound<'_, PyAny>, + fields: Option>, + access_profile: Option, + format: &str, +) -> PyResult { + let selectors = python_to_json(selectors) + .map_err(|error| conversion_error(py, "invalid_request", &error))?; + let Value::Object(selectors) = selectors else { + return Err(to_py_err( + py, + &MappedError::binding("invalid_request", "selectors must be a mapping"), + )); + }; + let mut request = + LookupRequest::default().options(record_options(py, fields, access_profile, format)?); + for (name, value) in selectors { + request = request + .selector(name, value) + .map_err(|error| sdk_error(py, &error))?; + } + Ok(request) +} + +fn set_metadata(dict: &Bound<'_, PyDict>, metadata: &ResponseMetadata) -> PyResult<()> { + dict.set_item("trace_id", metadata.trace_id().as_str())?; + dict.set_item("etag", metadata.etag().map(StrongEtag::as_str))?; + Ok(()) +} + +fn complete_value<'py>( + py: Python<'py>, + value: &impl Serialize, + metadata: &ResponseMetadata, +) -> PyResult> { + let result = PyDict::new(py); + result.set_item("kind", "complete")?; + result.set_item("value", serialize_to_python(py, value)?)?; + set_metadata(&result, metadata)?; + Ok(result.into_any()) +} + +fn complete_raw<'py>( + py: Python<'py>, + value: &RawDocument, + metadata: &ResponseMetadata, +) -> PyResult> { + let result = PyDict::new(py); + result.set_item("kind", "complete")?; + result.set_item("body", PyBytes::new(py, value.as_bytes()))?; + result.set_item("media_type", value.media_type())?; + set_metadata(&result, metadata)?; + Ok(result.into_any()) +} + +fn not_modified<'py>(py: Python<'py>, value: &NotModified) -> PyResult> { + let result = PyDict::new(py); + result.set_item("kind", "not_modified")?; + result.set_item("etag", value.etag.as_str())?; + result.set_item("trace_id", value.trace_id.as_str())?; + Ok(result.into_any()) +} + +fn conditional_value<'py, T: Serialize>( + py: Python<'py>, + value: &Conditional, +) -> PyResult> { + match value { + Conditional::Complete(Complete { value, metadata }) => complete_value(py, value, metadata), + Conditional::NotModified(value) => not_modified(py, value), + } +} + +fn conditional_raw<'py>( + py: Python<'py>, + value: &Conditional, +) -> PyResult> { + match value { + Conditional::Complete(Complete { value, metadata }) => complete_raw(py, value, metadata), + Conditional::NotModified(value) => not_modified(py, value), + } +} + +fn resource_page<'py, T: Serialize>( + py: Python<'py>, + value: Conditional>, +) -> PyResult> { + match value { + Conditional::NotModified(value) => not_modified(py, &value), + Conditional::Complete(Complete { value, metadata }) => { + let result = PyDict::new(py); + result.set_item("kind", "complete")?; + result.set_item("value", serialize_to_python(py, &value.value)?)?; + set_metadata(&result, &metadata)?; + if let Some(continuation) = value.continuation { + result.set_item( + "continuation", + serialize_to_python(py, &continuation.projection())?, + )?; + } else { + result.set_item("continuation", py.None())?; + } + Ok(result.into_any()) + } + } +} + +fn collection_page<'py, T: Serialize>( + py: Python<'py>, + value: Conditional>, +) -> PyResult> { + match value { + Conditional::NotModified(value) => not_modified(py, &value), + Conditional::Complete(Complete { value, metadata }) => { + let result = PyDict::new(py); + result.set_item("kind", "complete")?; + result.set_item("value", serialize_to_python(py, &value.value)?)?; + set_metadata(&result, &metadata)?; + if let Some(continuation) = value.continuation { + result.set_item( + "continuation", + serialize_to_python(py, &continuation.projection())?, + )?; + } else { + result.set_item("continuation", py.None())?; + } + Ok(result.into_any()) + } + } +} + +/// One deployment-bound Relay client with one private current-thread runtime. +#[pyclass(name = "RelayClient", module = "registry_relay_client")] +struct RelayClient { + inner: RustClient, + runtime: tokio::runtime::Runtime, +} + +#[pymethods] +impl RelayClient { + #[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, + ))] + #[allow(clippy::too_many_arguments)] + fn new( + py: Python<'_>, + base_url: &str, + authorization: Option<&Bound<'_, PyAny>>, + request_timeout_seconds: Option, + connect_timeout_seconds: Option, + user_agent: Option, + max_response_bytes: Option, + trusted_root_certificates: Option>, + ) -> PyResult { + let (authorization, private_key_jwt_trusted_root_certificates) = + authorization_from_python(authorization) + .map_err(|error| conversion_error(py, "configuration", &error))?; + let config = config_from_parts( + base_url, + &authorization, + private_key_jwt_trusted_root_certificates, + request_timeout_seconds, + connect_timeout_seconds, + user_agent, + max_response_bytes, + trusted_root_certificates, + ) + .map_err(|error| to_py_err(py, &map_config_error(&error)))?; + let inner = py + .detach(|| RustClient::new(config)) + .map_err(|error| sdk_error(py, &error))?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| { + PyRuntimeError::new_err("the client's internal runtime could not start") + })?; + Ok(Self { inner, runtime }) + } + + fn health<'py>(&self, py: Python<'py>) -> PyResult> { + let value = py + .detach(|| self.runtime.block_on(self.inner.health())) + .map_err(|error| sdk_error(py, &error))?; + complete_value(py, &value.value, &value.metadata) + } + + fn ready<'py>(&self, py: Python<'py>) -> PyResult> { + let value = py + .detach(|| self.runtime.block_on(self.inner.ready())) + .map_err(|error| sdk_error(py, &error))?; + complete_value(py, &value.value, &value.metadata) + } + + #[pyo3(signature = (etag=None))] + fn openapi<'py>(&self, py: Python<'py>, etag: Option<&str>) -> PyResult> { + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| self.runtime.block_on(self.inner.openapi(etag.as_ref()))) + .map_err(|error| sdk_error(py, &error))?; + conditional_raw(py, &value) + } + + #[pyo3(signature = (etag=None))] + fn service_metadata<'py>( + &self, + py: Python<'py>, + etag: Option<&str>, + ) -> PyResult> { + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime + .block_on(self.inner.service_metadata(etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + conditional_value(py, &value) + } + + #[pyo3(signature = (page_size=None, etag=None))] + fn resources<'py>( + &self, + py: Python<'py>, + page_size: Option, + etag: Option<&str>, + ) -> PyResult> { + let mut request = ResourceListRequest::default(); + if let Some(page_size) = page_size { + request = request + .page_size(page_size) + .map_err(|error| sdk_error(py, &error))?; + } + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime + .block_on(self.inner.resources(request, etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + resource_page(py, value) + } + + #[pyo3(signature = (continuation, etag=None))] + fn continue_resources<'py>( + &self, + py: Python<'py>, + continuation: &Bound<'_, PyAny>, + etag: Option<&str>, + ) -> PyResult> { + let continuation = python_to_json(continuation) + .map_err(|error| conversion_error(py, "invalid_request", &error))?; + let projection: ResourceContinuationProjection = serde_json::from_value(continuation) + .map_err(|_| { + to_py_err( + py, + &MappedError::binding( + "invalid_request", + "resource continuation must be an exact cursor mapping", + ), + ) + })?; + let continuation = ResourceContinuation::try_from_projection(projection) + .map_err(|error| sdk_error(py, &error))?; + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime + .block_on(self.inner.continue_resources(&continuation, etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + resource_page(py, value) + } + + #[pyo3(signature = (resource, etag=None))] + fn resource<'py>( + &self, + py: Python<'py>, + resource: &str, + etag: Option<&str>, + ) -> PyResult> { + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime + .block_on(self.inner.resource(resource, etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + conditional_value(py, &value) + } + + #[pyo3(signature = ( + resource, *, page_size=None, fields=None, access_profile=None, format="json", + filters=None, bbox=None, etag=None + ))] + #[allow(clippy::too_many_arguments)] + fn list_records<'py>( + &self, + py: Python<'py>, + resource: &str, + page_size: Option, + fields: Option>, + access_profile: Option, + format: &str, + filters: Option<&Bound<'_, PyAny>>, + bbox: Option<&Bound<'_, PyAny>>, + etag: Option<&str>, + ) -> PyResult> { + let request = + collection_request(py, page_size, fields, access_profile, format, filters, bbox)?; + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime + .block_on(self.inner.list_records(resource, &request, etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + collection_page(py, value) + } + + #[pyo3(signature = (continuation, etag=None))] + fn continue_list_records<'py>( + &self, + py: Python<'py>, + continuation: &Bound<'_, PyAny>, + etag: Option<&str>, + ) -> PyResult> { + self.continue_collection(py, continuation, etag, "list") + } + + #[pyo3(signature = ( + resource, search, *, page_size=None, fields=None, access_profile=None, format="json", + filters=None, bbox=None, etag=None + ))] + #[allow(clippy::too_many_arguments)] + fn search<'py>( + &self, + py: Python<'py>, + resource: &str, + search: &str, + page_size: Option, + fields: Option>, + access_profile: Option, + format: &str, + filters: Option<&Bound<'_, PyAny>>, + bbox: Option<&Bound<'_, PyAny>>, + etag: Option<&str>, + ) -> PyResult> { + let request = + collection_request(py, page_size, fields, access_profile, format, filters, bbox)?; + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime.block_on(self.inner.search_records( + resource, + search, + &request, + etag.as_ref(), + )) + }) + .map_err(|error| sdk_error(py, &error))?; + collection_page(py, value) + } + + #[pyo3(signature = (continuation, etag=None))] + fn continue_search<'py>( + &self, + py: Python<'py>, + continuation: &Bound<'_, PyAny>, + etag: Option<&str>, + ) -> PyResult> { + self.continue_collection(py, continuation, etag, "search") + } + + #[pyo3(signature = ( + resource, record_identifier, *, fields=None, access_profile=None, format="json", etag=None + ))] + #[allow(clippy::too_many_arguments)] + fn read_record<'py>( + &self, + py: Python<'py>, + resource: &str, + record_identifier: &str, + fields: Option>, + access_profile: Option, + format: &str, + etag: Option<&str>, + ) -> PyResult> { + let options = record_options(py, fields, access_profile, format)?; + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime.block_on(self.inner.read_record( + resource, + record_identifier, + &options, + etag.as_ref(), + )) + }) + .map_err(|error| sdk_error(py, &error))?; + conditional_value(py, &value) + } + + #[pyo3(signature = ( + resource, lookup, selectors, *, fields=None, access_profile=None, format="json", etag=None + ))] + #[allow(clippy::too_many_arguments)] + fn lookup<'py>( + &self, + py: Python<'py>, + resource: &str, + lookup: &str, + selectors: &Bound<'_, PyAny>, + fields: Option>, + access_profile: Option, + format: &str, + etag: Option<&str>, + ) -> PyResult> { + let request = lookup_request(py, selectors, fields, access_profile, format)?; + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime.block_on(self.inner.lookup_record( + resource, + lookup, + &request, + etag.as_ref(), + )) + }) + .map_err(|error| sdk_error(py, &error))?; + conditional_value(py, &value) + } + + #[pyo3(signature = (artifact_identifier, etag=None))] + fn artifact<'py>( + &self, + py: Python<'py>, + artifact_identifier: &str, + etag: Option<&str>, + ) -> PyResult> { + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime + .block_on(self.inner.artifact(artifact_identifier, etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + conditional_raw(py, &value) + } + + #[pyo3(signature = ( + agency, resource, version, *, key=None, constraints=None, offset=None, limit=None, + dimension_at_observation=None, format="json", etag=None + ))] + #[allow(clippy::too_many_arguments)] + fn sdmx_data<'py>( + &self, + py: Python<'py>, + agency: &str, + resource: &str, + version: &str, + key: Option, + constraints: Option<&Bound<'_, PyAny>>, + offset: Option, + limit: Option, + dimension_at_observation: Option, + format: &str, + etag: Option<&str>, + ) -> PyResult> { + let mut request = SdmxDataRequest::new(agency, resource, version) + .map_err(|error| sdk_error(py, &error))?; + if let Some(key) = key { + request = request.keyed(key).map_err(|error| sdk_error(py, &error))?; + } + for (name, value) in string_map(py, constraints, "constraint")? { + request = request + .constraint(name, value) + .map_err(|error| sdk_error(py, &error))?; + } + if let Some(offset) = offset { + request = request.offset(offset); + } + if let Some(limit) = limit { + request = request + .limit(limit) + .map_err(|error| sdk_error(py, &error))?; + } + if let Some(value) = dimension_at_observation { + request = request + .dimension_at_observation(value) + .map_err(|error| sdk_error(py, &error))?; + } + request = request.format(match format { + "json" => SdmxDataFormat::Json, + "csv" => SdmxDataFormat::Csv, + _ => { + return Err(to_py_err( + py, + &MappedError::binding( + "invalid_request", + "SDMX data format must be json or csv", + ), + )) + } + }); + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime + .block_on(self.inner.sdmx_data(&request, etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + conditional_raw(py, &value) + } + + #[pyo3(signature = (kind, agency, resource, version, *, etag=None))] + fn sdmx_structure<'py>( + &self, + py: Python<'py>, + kind: &str, + agency: &str, + resource: &str, + version: &str, + etag: Option<&str>, + ) -> PyResult> { + let kind = match kind { + "dataflow" => SdmxStructureKind::Dataflow, + "datastructure" => SdmxStructureKind::DataStructure, + _ => { + return Err(to_py_err( + py, + &MappedError::binding( + "invalid_request", + "SDMX structure kind must be dataflow or datastructure", + ), + )) + } + }; + let request = SdmxStructureRequest::new(kind, agency, resource, version) + .map_err(|error| sdk_error(py, &error))?; + let etag = parse_etag(py, etag)?; + let value = py + .detach(|| { + self.runtime + .block_on(self.inner.sdmx_structure(&request, etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + conditional_raw(py, &value) + } +} + +impl RelayClient { + fn continue_collection<'py>( + &self, + py: Python<'py>, + continuation: &Bound<'_, PyAny>, + etag: Option<&str>, + kind: &str, + ) -> PyResult> { + let value = python_to_json(continuation) + .map_err(|error| conversion_error(py, "invalid_request", &error))?; + let Value::Object(object) = &value else { + return Err(to_py_err( + py, + &MappedError::binding("invalid_request", "continuation must be a mapping"), + )); + }; + if object + .keys() + .any(|field| !["route", "cursor", "format", "accessProfile"].contains(&field.as_str())) + { + return Err(to_py_err( + py, + &MappedError::binding("invalid_request", "continuation is invalid"), + )); + } + let Some(Value::Object(route)) = object.get("route") else { + return Err(to_py_err( + py, + &MappedError::binding("invalid_request", "continuation is invalid"), + )); + }; + let route_fields = match route.get("kind").and_then(Value::as_str) { + Some("records") => &["kind", "resource"][..], + Some("search") => &["kind", "resource", "search"][..], + _ => { + return Err(to_py_err( + py, + &MappedError::binding("invalid_request", "continuation is invalid"), + )); + } + }; + if route.len() != route_fields.len() + || route + .keys() + .any(|field| !route_fields.contains(&field.as_str())) + || object + .get("accessProfile") + .is_some_and(|value| !value.is_string()) + { + return Err(to_py_err( + py, + &MappedError::binding("invalid_request", "continuation is invalid"), + )); + } + let projection: CollectionContinuationProjection = + serde_json::from_value(value).map_err(|_| { + to_py_err( + py, + &MappedError::binding("invalid_request", "continuation is invalid"), + ) + })?; + let route_matches = matches!( + (&projection.route, kind), + (CollectionRouteProjection::Records { .. }, "list") + | (CollectionRouteProjection::Search { .. }, "search") + ); + if !route_matches { + return Err(to_py_err( + py, + &MappedError::binding( + "invalid_request", + "continuation does not match the method that consumes it", + ), + )); + } + let continuation = CollectionContinuation::try_from_projection(projection) + .map_err(|error| sdk_error(py, &error))?; + let etag = parse_etag(py, etag)?; + let response = py + .detach(|| { + self.runtime + .block_on(self.inner.continue_collection(&continuation, etag.as_ref())) + }) + .map_err(|error| sdk_error(py, &error))?; + collection_page(py, response) + } +} + +#[pymodule] +pub fn registry_relay_client(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add( + "RelayClientError", + module.py().get_type::(), + )?; + Ok(()) +} diff --git a/crates/registry-relay-client-py/tests/python/bootstrap.py b/crates/registry-relay-client-py/tests/python/bootstrap.py new file mode 100644 index 000000000..36711d4dd --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/bootstrap.py @@ -0,0 +1,51 @@ +"""Build and import the Relay PyO3 module for stdlib-only tests.""" + +from __future__ import annotations + +import pathlib +import platform +import shutil +import subprocess +import sys + +_CRATE_ROOT = pathlib.Path(__file__).resolve().parents[2] +_WORKSPACE_ROOT = _CRATE_ROOT.parents[1] +_MODULE_NAME = "registry_relay_client" +_TARGET_DEBUG = _WORKSPACE_ROOT / "target" / "debug" +_IMPORT_DIR = _TARGET_DEBUG / "relay_python_module" +_built = False + + +def _library() -> pathlib.Path: + suffix = {"Darwin": ".dylib", "Linux": ".so"}.get(platform.system()) + if suffix is None: + raise RuntimeError("the Relay Python test bootstrap supports macOS and Linux") + return _TARGET_DEBUG / f"lib{_MODULE_NAME}{suffix}" + + +def ensure_built() -> None: + global _built + if _built: + return + subprocess.run( + [ + "cargo", + "build", + "--locked", + "-p", + "registry-relay-client-py", + "--lib", + "--features", + "registry-relay-client-py/extension-module", + ], + cwd=_WORKSPACE_ROOT, + check=True, + ) + source = _library() + if not source.is_file(): + raise RuntimeError(f"cargo did not produce {source}") + _IMPORT_DIR.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, _IMPORT_DIR / f"{_MODULE_NAME}.so") + if str(_IMPORT_DIR) not in sys.path: + sys.path.insert(0, str(_IMPORT_DIR)) + _built = True diff --git a/crates/registry-relay-client-py/tests/python/fixtures/token-ca.pem b/crates/registry-relay-client-py/tests/python/fixtures/token-ca.pem new file mode 100644 index 000000000..ec00f354b --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/fixtures/token-ca.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDPzCCAiegAwIBAgIUWxQfgXNNrirXoxkFFEuthhScZEEwDQYJKoZIhvcNAQEL +BQAwLzEtMCsGA1UEAwwkcmVnaXN0cnktcmVsYXktY2xpZW50LXB5dGhvbi10ZXN0 +LWNhMB4XDTI2MDgxMTEyNDQxOFoXDTM2MDgwODEyNDQxOFowLzEtMCsGA1UEAwwk +cmVnaXN0cnktcmVsYXktY2xpZW50LXB5dGhvbi10ZXN0LWNhMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAp+qGcYgBVnepE5C4vbcGn8ZHlojMixbd6z2v +FZYhy5mCwiPxvQOrNNYNQqu8MCeO9EI7CdvEQ+KoI76LxIJLISBxN6QkN+AHrbxr +90OzqXbGddqRc78KZRuKKlFvVmveogDhHoKe473vFfq15ZmAyfdS7B87X6IEkYiT +p3atqp1uMy7GTtxCfLWHXFiG2TALHv5wymaRDRxOQpe840YRLdipJlebXcXNP6s8 +mGV/VvKUkGyQTRPRQupkIcGrLRhDgkYvd0DCtjK+qfV7DydKvDzyNXwvnOBLGj9O +gP0mu0ChmbdD9nbXt33jihWdbV2LKwAhOFYbhKCZkfaOkiz4QQIDAQABo1MwUTAd +BgNVHQ4EFgQUsyF6J/vFKGMY3wXPUSqC0ypP5+EwHwYDVR0jBBgwFoAUsyF6J/vF +KGMY3wXPUSqC0ypP5+EwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC +AQEAf/7v4glBNzfaF4RPFDKqod7tzNI7u+Za6kZp+RXC8shmUMzeynEAEGbbGkVa +qQhMlwY9DGDeir8znuc7p3zM6nBk5PGkjonXXqgW1V8f15G1Q917yoLe26iTf3KQ ++xozl3N5gqhB7jCy9yN6YRDqLMstVmEVF5xhnjbDDAGbLXSN9WTpQeX2ylyxU/yp +ljyHAlK2etHcVkGVuK5+fXqkzKP5kjI9uGQNa4LRP+8KMP5XDykilzbrzShmxdR4 +70e5PgzqtVgkv4Zy85wyM0pwAou/kSso3MdIiYOKcT6YqJHdBFr2wWxJNBp9+jhE +AKak3FGHjOJg3JPCRmTPGX6jWw== +-----END CERTIFICATE----- diff --git a/crates/registry-relay-client-py/tests/python/relay_server.py b/crates/registry-relay-client-py/tests/python/relay_server.py new file mode 100644 index 000000000..493390531 --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/relay_server.py @@ -0,0 +1,169 @@ +"""Small loopback Relay responder used by the native binding tests.""" + +from __future__ import annotations + +import http.server +import json +import threading +from dataclasses import dataclass +from typing import Callable, Mapping + +TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" +TRACEPARENT = f"00-{TRACE_ID}-00f067aa0ba902b7-01" +ETAG = f'"{"a" * 64}"' + + +@dataclass(frozen=True) +class Request: + method: str + target: str + headers: Mapping[str, str] + body: bytes + + +@dataclass(frozen=True) +class Response: + status: int + media_type: str = "application/json" + body: bytes = b"" + headers: Mapping[str, str] | None = None + + +def json_response(value: object, *, headers: Mapping[str, str] | None = None) -> Response: + return Response(200, body=json.dumps(value, separators=(",", ":")).encode(), headers=headers) + + +class RelayServer: + def __init__(self, responder: Callable[[Request], Response]): + self.requests: list[Request] = [] + self._responder = responder + owner = self + + class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 + self._handle() + + def do_POST(self) -> None: # noqa: N802 + self._handle() + + def _handle(self) -> None: + length = int(self.headers.get("content-length", "0")) + request = Request( + self.command, + self.path, + {key.lower(): value for key, value in self.headers.items()}, + self.rfile.read(length), + ) + owner.requests.append(request) + response = owner._responder(request) + self.send_response(response.status) + headers = dict(response.headers or {}) + headers.setdefault("traceparent", TRACEPARENT) + if response.status != 304: + headers.setdefault("content-type", response.media_type) + headers.setdefault("content-length", str(len(response.body))) + else: + headers.setdefault("content-length", "0") + for name, value in headers.items(): + self.send_header(name, value) + self.end_headers() + if response.status != 304: + self.wfile.write(response.body) + + def log_message(self, _format: str, *_args: object) -> None: + pass + + self._server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + @property + def base_url(self) -> str: + host, port = self._server.server_address + return f"http://{host}:{port}/prefix" + + def __enter__(self) -> "RelayServer": + self._thread.start() + return self + + def __exit__(self, *_args: object) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=2) + + +def service_metadata() -> dict[str, object]: + return { + "registryIdentifier": "urn:example:registry", + "name": "Example Registry", + "authority": {"identifier": "urn:example:authority", "name": "Authority"}, + "operator": None, + "authoritativeScope": "example", + "product": {"name": "Registry Relay", "version": "0.19.0"}, + "apiBinding": {"name": "Registry Relay V2", "version": "2"}, + "alignmentTargets": [], + "capabilities": [], + "links": {"self": "/v2", "resources": "/v2/resources", "openapi": "/openapi.json"}, + } + + +def record_metadata() -> dict[str, object]: + return { + "operationIdentifier": "records", + "accessProfile": "public", + "family": "consultation", + "pattern": "list", + "disclosureProfile": "public", + "contractRevision": "sha256:revision", + "sourceRevision": {"profile": "snapshot", "status": "known", "value": "r1"}, + "selectedFields": [], + "links": { + "self": "/v2/resources/people/records", + "context": "/v2/artifacts/context", + "schema": "/v2/artifacts/schema", + "semanticModel": "/v2/artifacts/model", + }, + } + + +def record(identifier: str = "one") -> dict[str, object]: + return { + "registryIdentifier": "urn:example:registry", + "recordIdentifier": identifier, + "revisionIdentifier": "r1", + "lifecycleState": "active", + "schemaReference": "/v2/artifacts/schema", + "semanticModelReference": "/v2/artifacts/model", + "authorityIdentifier": "urn:example:authority", + "recordedAt": "2026-08-11T00:00:00Z", + "domainData": {"label": "Example"}, + } + + +def record_collection(next_cursor: str | None) -> dict[str, object]: + return { + "items": [record()], + "pageInfo": {"nextCursor": next_cursor}, + "meta": record_metadata(), + } + + +def resource_document() -> dict[str, object]: + return { + "resourceIdentifier": "people", + "title": "People", + "description": "Example resource", + "semanticClass": "urn:example:Person", + "enumerationPosture": "public", + "capabilities": [], + "links": {"self": "/v2/resources/people"}, + } + + +def resource_collection(next_cursor: str | None) -> dict[str, object]: + return { + "items": [resource_document()], + "pageInfo": {"nextCursor": next_cursor}, + "meta": {"registryIdentifier": "urn:example:registry"}, + } diff --git a/crates/registry-relay-client-py/tests/python/test_construction.py b/crates/registry-relay-client-py/tests/python/test_construction.py new file mode 100644 index 000000000..d03070860 --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/test_construction.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import pathlib +import sys +import unittest + +TESTS = pathlib.Path(__file__).resolve().parent +TOKEN_CA = (TESTS / "fixtures" / "token-ca.pem").read_bytes() +PRIVATE_JWK = { + "kty": "OKP", + "crv": "Ed25519", + "d": "2oPoxdKuO7Kpd-3JLfNW_4xwpFxItbS-fxe03ZybYEw", + "x": "1aj_rLJsGFgw-5v925EMmeZj5JqP44xegafEKfZbdxc", + "alg": "EdDSA", + "kid": "client-key-1", +} +sys.path.insert(0, str(TESTS)) +import bootstrap # noqa: E402 + +bootstrap.ensure_built() +import registry_relay_client as relay # noqa: E402 + + +class ConstructionTest(unittest.TestCase): + def test_public_static_and_private_key_jwt_clients_construct_offline(self): + self.assertIsNotNone(relay.RelayClient("http://127.0.0.1:9/prefix")) + self.assertIsNotNone( + relay.RelayClient( + "http://127.0.0.1:9/prefix", authorization="placeholder-token" + ) + ) + self.assertIsNotNone( + relay.RelayClient( + "http://127.0.0.1:9/prefix", + authorization={ + "private_key_jwt": { + "token_endpoint": "http://127.0.0.1:9/token", + "client_id": "client", + "client_key": PRIVATE_JWK, + "trusted_root_certificates": TOKEN_CA, + } + }, + request_timeout_seconds=1.0, + connect_timeout_seconds=1.0, + user_agent="relay-python-test", + max_response_bytes=4096, + ) + ) + + def test_configuration_errors_are_structured_and_redacted(self): + secret = "canary-secret-that-must-not-render" + with self.assertRaises(relay.RelayClientError) as raised: + relay.RelayClient( + "https://relay.example", + authorization={"private_key_jwt": {}, "static": secret}, + ) + error = raised.exception + self.assertEqual(error.kind, "configuration") + self.assertIsNone(error.code) + self.assertIsNone(error.status) + self.assertIsNone(error.trace_id) + self.assertIsNone(error.retry_after_seconds) + self.assertIsNone(error.transport_kind) + self.assertIsNone(error.token_kind) + self.assertNotIn(secret, str(error)) + self.assertNotIn(secret, repr(error)) + + def test_private_key_jwt_trusted_roots_reject_bad_material_without_exposure(self): + canary = b"canary-private-ca-material" + with self.assertRaises(relay.RelayClientError) as raised: + relay.RelayClient( + "https://relay.example", + authorization={ + "private_key_jwt": { + "token_endpoint": "https://tokens.example/token", + "client_id": "client", + "client_key": PRIVATE_JWK, + "trusted_root_certificates": canary, + } + }, + ) + self.assertEqual(raised.exception.kind, "token") + self.assertEqual(raised.exception.token_kind, "configuration") + self.assertNotIn(canary.decode(), str(raised.exception)) + self.assertNotIn(canary.decode(), repr(raised.exception)) + + def test_private_key_jwt_shape_rejects_extra_fields_before_conversion(self): + private_key_jwt: dict[str, object] = { + "token_endpoint": "https://tokens.example/token", + "client_id": "client", + "client_key": PRIVATE_JWK, + "trusted_root_certificates": TOKEN_CA, + } + for index in range(20_000): + private_key_jwt[f"canary-extra-{index}"] = object() + with self.assertRaises(relay.RelayClientError) as raised: + relay.RelayClient( + "https://relay.example", + authorization={"private_key_jwt": private_key_jwt}, + ) + self.assertEqual(raised.exception.kind, "configuration") + self.assertEqual( + str(raised.exception), + 'authorization["private_key_jwt"] carries an unsupported field', + ) + self.assertNotIn("canary-extra", str(raised.exception)) + + def test_private_key_jwt_fields_share_one_conversion_budget(self): + with self.assertRaises(relay.RelayClientError) as raised: + relay.RelayClient( + "https://relay.example", + authorization={ + "private_key_jwt": { + "token_endpoint": "https://tokens.example/token", + "client_id": "client", + "client_key": PRIVATE_JWK, + "audience": "a" * (3 * 1024 * 1024), + "user_agent": "b" * (3 * 1024 * 1024), + "trusted_root_certificates": TOKEN_CA, + } + }, + ) + self.assertEqual(raised.exception.kind, "configuration") + self.assertEqual( + str(raised.exception), + "the Python object graph exceeds the conversion text bound", + ) + + def test_private_key_jwt_trusted_roots_are_bounded_before_copy(self): + oversized = b"canary-oversized-ca" + b"x" * 1_048_576 + with self.assertRaises(relay.RelayClientError) as raised: + relay.RelayClient( + "https://relay.example", + authorization={ + "private_key_jwt": { + "token_endpoint": "https://tokens.example/token", + "client_id": "client", + "client_key": PRIVATE_JWK, + "trusted_root_certificates": oversized, + } + }, + ) + self.assertEqual(raised.exception.kind, "configuration") + self.assertEqual( + str(raised.exception), + 'authorization["private_key_jwt"]["trusted_root_certificates"] ' + "exceeds the accepted byte bound", + ) + self.assertNotIn("canary-oversized-ca", str(raised.exception)) + + def test_cyclic_and_overdeep_authorization_graphs_fail_without_recursing(self): + cyclic: dict[str, object] = {} + cyclic["private_key_jwt"] = cyclic + with self.assertRaises(relay.RelayClientError) as cyclic_error: + relay.RelayClient("https://relay.example", authorization=cyclic) + self.assertEqual(cyclic_error.exception.kind, "configuration") + + nested: object = None + for _ in range(140): + nested = [nested] + with self.assertRaises(relay.RelayClientError): + relay.RelayClient("https://relay.example", authorization=nested) + + def test_unsafe_base_url_and_zero_bounds_are_rejected_by_the_sdk(self): + for kwargs in ( + {"base_url": "https://secret@relay.example"}, + {"base_url": "https://relay.example", "request_timeout_seconds": 0.0}, + {"base_url": "https://relay.example", "max_response_bytes": 0}, + ): + with self.subTest(kwargs=kwargs): + with self.assertRaises(relay.RelayClientError) as raised: + relay.RelayClient(**kwargs) + self.assertEqual(raised.exception.kind, "configuration") + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-relay-client-py/tests/python/test_drift.py b/crates/registry-relay-client-py/tests/python/test_drift.py new file mode 100644 index 000000000..33484e85d --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/test_drift.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import ast +import pathlib +import sys +import unittest + +TESTS = pathlib.Path(__file__).resolve().parent +CRATE = TESTS.parents[1] +PACKAGE = CRATE / "python" / "registry_relay_client" +STUB = PACKAGE / "__init__.pyi" +sys.path.insert(0, str(TESTS)) +import bootstrap # noqa: E402 + +bootstrap.ensure_built() +import registry_relay_client as relay # noqa: E402 + +ERROR_ATTRIBUTES = { + "kind", + "code", + "status", + "trace_id", + "retry_after_seconds", + "transport_kind", + "token_kind", +} + + +def class_members(node: ast.ClassDef) -> set[str]: + result: set[str] = set() + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + result.add("__new__" if item.name == "__init__" else item.name) + elif isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name): + result.add(item.target.id) + return result + + +class DriftTest(unittest.TestCase): + def setUp(self): + tree = ast.parse(STUB.read_text(encoding="utf-8")) + self.stub_classes = { + node.name: node for node in tree.body if isinstance(node, ast.ClassDef) + } + self.classes = { + name: node + for name, node in self.stub_classes.items() + if name in {"RelayClient", "RelayClientError"} + } + + def test_stub_and_runtime_top_level_classes_match_both_directions(self): + self.assertEqual(set(self.classes), {"RelayClient", "RelayClientError"}) + self.assertEqual( + {name for name in dir(relay) if not name.startswith("_")}, + {"RelayClient", "RelayClientError"}, + ) + + def test_client_methods_match_both_directions(self): + stub = class_members(self.classes["RelayClient"]) + live = set(vars(relay.RelayClient)) - {"__doc__", "__module__"} + self.assertEqual(stub, live) + + def test_error_attributes_and_inheritance_are_pinned(self): + self.assertEqual( + class_members(self.classes["RelayClientError"]), ERROR_ATTRIBUTES + ) + self.assertTrue(issubclass(relay.RelayClientError, Exception)) + + def test_required_and_optional_typed_dict_keys_are_pinned(self): + private_required = self.stub_classes["_PrivateKeyJwtRequired"] + self.assertEqual( + class_members(private_required), + {"token_endpoint", "client_id", "client_key"}, + ) + private_config = self.stub_classes["PrivateKeyJwtConfig"] + self.assertEqual( + [base.id for base in private_config.bases if isinstance(base, ast.Name)], + ["_PrivateKeyJwtRequired"], + ) + self.assertEqual( + class_members(private_config), + { + "audience", + "assertion_lifetime_seconds", + "refresh_margin_seconds", + "request_timeout_seconds", + "connect_timeout_seconds", + "user_agent", + "trusted_root_certificates", + }, + ) + self.assertTrue( + any( + keyword.arg == "total" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value is False + for keyword in private_config.keywords + ) + ) + + continuation_required = self.stub_classes[ + "_CollectionContinuationRequired" + ] + self.assertEqual( + class_members(continuation_required), {"route", "cursor", "format"} + ) + continuation = self.stub_classes["CollectionContinuation"] + self.assertEqual( + [base.id for base in continuation.bases if isinstance(base, ast.Name)], + ["_CollectionContinuationRequired"], + ) + self.assertEqual(class_members(continuation), {"accessProfile"}) + self.assertTrue( + any( + keyword.arg == "total" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value is False + for keyword in continuation.keywords + ) + ) + + def test_pep_561_and_package_metadata_files_exist(self): + self.assertTrue((PACKAGE / "py.typed").is_file()) + self.assertTrue((PACKAGE / "__init__.py").is_file()) + self.assertTrue((CRATE / "pyproject.toml").is_file()) + self.assertTrue((CRATE / "README.md").is_file()) + self.assertTrue((CRATE / "LICENSE").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-relay-client-py/tests/python/test_errors.py b/crates/registry-relay-client-py/tests/python/test_errors.py new file mode 100644 index 000000000..9ec112b08 --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/test_errors.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import json +import pathlib +import sys +import unittest + +TESTS = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS)) +import bootstrap # noqa: E402 +from relay_server import RelayServer, Request, Response, TRACE_ID # noqa: E402 + +bootstrap.ensure_built() +import registry_relay_client as relay # noqa: E402 + + +def rate_limit_problem(extra: bool = False) -> bytes: + body: dict[str, object] = { + "type": "https://id.registrystack.org/problems/registry-relay/consultation/rate_limited", + "title": "Consultation quota is exhausted", + "status": 429, + "detail": "the consultation quota is exhausted", + "code": "consultation.rate_limited", + "traceId": TRACE_ID, + } + if extra: + body["rejectedValue"] = "canary-value" + return json.dumps(body).encode() + + +class ErrorTest(unittest.TestCase): + def test_exact_problem_maps_to_stable_value_free_attributes(self): + with RelayServer( + lambda _request: Response( + 429, + "application/problem+json", + rate_limit_problem(), + {"retry-after": "7"}, + ) + ) as server: + client = relay.RelayClient(server.base_url) + with self.assertRaises(relay.RelayClientError) as raised: + client.list_records("people") + error = raised.exception + self.assertEqual(error.kind, "problem") + self.assertEqual(error.code, "consultation.rate_limited") + self.assertEqual(error.status, 429) + self.assertEqual(error.trace_id, TRACE_ID) + self.assertEqual(error.retry_after_seconds, 7) + self.assertIsNone(error.transport_kind) + self.assertIsNone(error.token_kind) + self.assertNotIn("canary", str(error)) + + def test_non_exact_problem_is_protocol_and_drops_the_body(self): + with RelayServer( + lambda _request: Response( + 429, + "application/problem+json", + rate_limit_problem(extra=True), + {"retry-after": "7"}, + ) + ) as server: + with self.assertRaises(relay.RelayClientError) as raised: + relay.RelayClient(server.base_url).list_records("people") + error = raised.exception + self.assertEqual(error.kind, "protocol") + self.assertEqual(error.code, "problem") + self.assertEqual(error.status, 429) + self.assertEqual(error.trace_id, TRACE_ID) + self.assertIsNone(error.retry_after_seconds) + self.assertNotIn("canary-value", str(error)) + self.assertNotIn("canary-value", repr(error)) + + def test_transport_and_request_failures_have_closed_discriminants(self): + client = relay.RelayClient( + "http://127.0.0.1:9", request_timeout_seconds=0.2, connect_timeout_seconds=0.2 + ) + with self.assertRaises(relay.RelayClientError) as transport: + client.health() + self.assertEqual(transport.exception.kind, "transport") + self.assertIn(transport.exception.transport_kind, {"connect", "exchange"}) + + canary = "canary-selector-value" + with self.assertRaises(relay.RelayClientError) as request: + client.lookup("people", "by-code", {"code": [canary]}) + self.assertEqual(request.exception.kind, "invalid_request") + self.assertNotIn(canary, str(request.exception)) + + def test_cyclic_request_graph_fails_locally(self): + filters: dict[str, object] = {} + filters["cycle"] = filters + client = relay.RelayClient("http://127.0.0.1:9") + with self.assertRaises(relay.RelayClientError) as raised: + client.list_records("people", filters=filters) + self.assertEqual(raised.exception.kind, "invalid_request") + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-relay-client-py/tests/python/test_gil.py b/crates/registry-relay-client-py/tests/python/test_gil.py new file mode 100644 index 000000000..2e7f9b2ea --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/test_gil.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import pathlib +import sys +import threading +import time +import unittest + +TESTS = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS)) +import bootstrap # noqa: E402 +from relay_server import RelayServer, Request, json_response # noqa: E402 + +bootstrap.ensure_built() +import registry_relay_client as relay # noqa: E402 + + +class GilTest(unittest.TestCase): + def test_blocking_exchange_releases_the_gil_during_io(self): + request_started = threading.Event() + + def respond(_request: Request): + request_started.set() + time.sleep(0.4) + return json_response({"status": "ok"}) + + with RelayServer(respond) as server: + client = relay.RelayClient(server.base_url) + result: list[dict[str, object]] = [] + worker = threading.Thread(target=lambda: result.append(client.health())) + started = time.monotonic() + worker.start() + self.assertTrue(request_started.wait(timeout=1.0)) + observed = time.monotonic() - started + # If the native method retained the GIL, this thread could not + # return from Event.wait until the 400ms response had completed. + self.assertLess(observed, 0.25) + counter = 0 + deadline = time.monotonic() + 0.1 + while time.monotonic() < deadline: + counter += 1 + self.assertGreater(counter, 0) + worker.join(timeout=2) + self.assertFalse(worker.is_alive()) + self.assertEqual(result[0]["kind"], "complete") + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-relay-client-py/tests/python/test_happy_path.py b/crates/registry-relay-client-py/tests/python/test_happy_path.py new file mode 100644 index 000000000..8c26ee75b --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/test_happy_path.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +import json +import pathlib +import sys +import unittest +from urllib.parse import urlsplit + +TESTS = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS)) +import bootstrap # noqa: E402 +from relay_server import ( # noqa: E402 + RelayServer, + Request, + Response, + json_response, + record, + record_collection, + record_metadata, + resource_collection, + resource_document, + service_metadata, +) + +bootstrap.ensure_built() +import registry_relay_client as relay # noqa: E402 + + +class HappyPathTest(unittest.TestCase): + def test_every_fixed_method_delegates_one_exchange_and_returns_plain_values(self): + def respond(request: Request) -> Response: + path = urlsplit(request.target).path + if path in {"/prefix/health", "/prefix/ready"}: + return json_response({"status": "ok"}) + if path == "/prefix/openapi.json": + return Response(200, body=b'{"openapi":"3.1.0"}') + if path == "/prefix/v2": + return json_response(service_metadata()) + if path == "/prefix/v2/resources": + return json_response(resource_collection(None)) + if path == "/prefix/v2/resources/people": + return json_response( + { + "data": resource_document(), + "meta": {"registryIdentifier": "urn:example:registry"}, + } + ) + if path == "/prefix/v2/resources/people/records": + return json_response(record_collection(None)) + if path == "/prefix/v2/resources/people/records/one": + return json_response({"data": record(), "meta": record_metadata()}) + if path == "/prefix/v2/resources/people/lookups/by-code": + self.assertEqual(request.method, "POST") + self.assertEqual(json.loads(request.body), {"selectors": {"code": "one"}}) + return json_response({"data": record(), "meta": record_metadata()}) + if path == "/prefix/v2/resources/people/searches/nearby": + return json_response(record_collection(None)) + if path == "/prefix/v2/artifacts/schema": + return Response(200, "application/schema+json", b'{"type":"object"}') + if path.startswith("/prefix/sdmx/v2/data/dataflow/AGENCY/FLOW/1.0.0"): + return Response( + 200, + "application/vnd.sdmx.data+json;version=2.1.0", + b'{"dataSets":[]}', + ) + if path == "/prefix/sdmx/v2/structure/dataflow/AGENCY/FLOW/1.0.0": + return Response( + 200, + "application/vnd.sdmx.structure+json;version=2.1.0", + b'{"data":{"dataflows":[]}}', + ) + raise AssertionError(f"unexpected request {request.method} {request.target}") + + with RelayServer(respond) as server: + client = relay.RelayClient(server.base_url, authorization="static-token") + self.assertEqual(client.health()["value"], {"status": "ok"}) + self.assertEqual(client.ready()["kind"], "complete") + self.assertIsInstance(client.openapi()["body"], bytes) + self.assertEqual(client.service_metadata()["value"]["name"], "Example Registry") + self.assertEqual(client.resources()["value"]["items"][0]["resourceIdentifier"], "people") + self.assertEqual(client.resource("people")["value"]["data"]["title"], "People") + self.assertEqual(client.list_records("people")["value"]["items"][0]["recordIdentifier"], "one") + self.assertEqual(client.read_record("people", "one")["value"]["data"]["domainData"]["label"], "Example") + self.assertEqual(client.lookup("people", "by-code", {"code": "one"})["value"]["data"]["recordIdentifier"], "one") + self.assertEqual(client.search("people", "nearby", bbox=[10, 20, 11, 21])["value"]["items"][0]["recordIdentifier"], "one") + artifact = client.artifact("schema") + self.assertEqual(artifact["body"], b'{"type":"object"}') + self.assertEqual(artifact["media_type"], "application/schema+json") + data = client.sdmx_data( + "AGENCY", + "FLOW", + "1.0.0", + constraints={"TIME_PERIOD": "ge:2020+le:2024"}, + limit=10, + ) + self.assertEqual(data["body"], b'{"dataSets":[]}') + structure = client.sdmx_structure("dataflow", "AGENCY", "FLOW", "1.0.0") + self.assertEqual(structure["media_type"], "application/vnd.sdmx.structure+json;version=2.1.0") + + self.assertEqual(len(server.requests), 13) + for request in server.requests[:3]: + self.assertNotIn("authorization", request.headers) + for request in server.requests[3:]: + self.assertEqual(request.headers.get("authorization"), "Bearer static-token") + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-relay-client-py/tests/python/test_pagination.py b/crates/registry-relay-client-py/tests/python/test_pagination.py new file mode 100644 index 000000000..9a8ae6e67 --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/test_pagination.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import pathlib +import sys +import unittest +from urllib.parse import parse_qs, urlsplit + +TESTS = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS)) +import bootstrap # noqa: E402 +from relay_server import ( # noqa: E402 + RelayServer, + Request, + json_response, + record_collection, + resource_collection, +) + +bootstrap.ensure_built() +import registry_relay_client as relay # noqa: E402 + + +class PaginationTest(unittest.TestCase): + def test_resource_and_collection_continuations_round_trip_as_plain_stateless_values(self): + def respond(request: Request): + target = urlsplit(request.target) + query = parse_qs(target.query) + if target.path == "/prefix/v2/resources": + return json_response( + resource_collection(None if "cursor" in query else "resource_cursor") + ) + if target.path == "/prefix/v2/resources/people/records": + return json_response( + record_collection(None if "cursor" in query else "record_cursor") + ) + if target.path == "/prefix/v2/resources/people/searches/nearby": + response = json_response( + record_collection(None if "cursor" in query else "search_cursor") + ) + return type(response)( + response.status, + "application/ld+json", + response.body, + response.headers, + ) + raise AssertionError(request.target) + + with RelayServer(respond) as server: + client = relay.RelayClient(server.base_url) + + first_resources = client.resources(page_size=2) + self.assertEqual(first_resources["continuation"], {"cursor": "resource_cursor"}) + second_resources = client.continue_resources(first_resources["continuation"]) + self.assertIsNone(second_resources["continuation"]) + + first_records = client.list_records( + "people", + page_size=5, + fields=["recordIdentifier"], + access_profile="public", + filters={"category": "active"}, + ) + record_continuation = first_records["continuation"] + self.assertEqual( + record_continuation, + { + "route": {"kind": "records", "resource": "people"}, + "cursor": "record_cursor", + "format": "json", + "accessProfile": "public", + }, + ) + second_records = client.continue_list_records(record_continuation) + self.assertIsNone(second_records["continuation"]) + + first_search = client.search( + "people", "nearby", page_size=3, format="json-ld" + ) + search_continuation = first_search["continuation"] + self.assertEqual(search_continuation["route"]["kind"], "search") + self.assertEqual(search_continuation["route"]["search"], "nearby") + self.assertEqual(search_continuation["format"], "json-ld") + self.assertNotIn("accessProfile", search_continuation) + second_search = client.continue_search(search_continuation) + self.assertIsNone(second_search["continuation"]) + + queries = [parse_qs(urlsplit(request.target).query) for request in server.requests] + self.assertEqual(queries[1], {"cursor": ["resource_cursor"]}) + self.assertEqual(queries[3], {"cursor": ["record_cursor"], "accessProfile": ["public"]}) + self.assertNotIn("fields", queries[3]) + self.assertNotIn("pageSize", queries[3]) + self.assertNotIn("category", queries[3]) + self.assertEqual(queries[5], {"cursor": ["search_cursor"]}) + + def test_continuations_are_route_specific_and_exact(self): + client = relay.RelayClient("http://127.0.0.1:9") + for continuation in ( + "cursor", + {"cursor": "cursor", "unexpected": "value"}, + ): + with self.subTest(resource_continuation=continuation): + with self.assertRaises(relay.RelayClientError): + client.continue_resources(continuation) + + search = { + "route": {"kind": "search", "resource": "people", "search": "nearby"}, + "cursor": "cursor", + "format": "json", + } + with self.assertRaises(relay.RelayClientError) as wrong_method: + client.continue_list_records(search) + self.assertEqual(wrong_method.exception.kind, "invalid_request") + + records = { + "route": {"kind": "records", "resource": "people"}, + "cursor": "cursor", + "format": "json", + "unexpected": "value", + } + with self.assertRaises(relay.RelayClientError): + client.continue_list_records(records) + + records = { + "route": {"kind": "records", "resource": "people"}, + "cursor": "cursor", + "format": "json", + "accessProfile": None, + } + with self.assertRaises(relay.RelayClientError): + client.continue_list_records(records) + + records = { + "route": { + "kind": "records", + "resource": "people", + "unexpected": "value", + }, + "cursor": "cursor", + "format": "json", + } + with self.assertRaises(relay.RelayClientError): + client.continue_list_records(records) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-relay-client-py/tests/python/test_raw_body.py b/crates/registry-relay-client-py/tests/python/test_raw_body.py new file mode 100644 index 000000000..853d70a77 --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/test_raw_body.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import pathlib +import sys +import unittest +from urllib.parse import urlsplit + +TESTS = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS)) +import bootstrap # noqa: E402 +from relay_server import ETAG, RelayServer, Request, Response # noqa: E402 + +bootstrap.ensure_built() +import registry_relay_client as relay # noqa: E402 + + +class RawBodyTest(unittest.TestCase): + def test_raw_documents_remain_bytes_and_conditional_requests_are_explicit(self): + def respond(request: Request) -> Response: + path = urlsplit(request.target).path + if path == "/prefix/openapi.json": + if request.headers.get("if-none-match") == ETAG: + return Response(304, headers={"etag": ETAG}) + return Response(200, body=b'{"openapi":"3.1.0"}', headers={"etag": ETAG}) + if path == "/prefix/v2/artifacts/context": + return Response(200, "application/ld+json", b'{"@context":{}}') + if path.endswith("/KEY"): + return Response( + 200, + "application/vnd.sdmx.data+csv;version=2.1.0", + b"DATAFLOW,OBS_VALUE\nFLOW,1\n", + ) + raise AssertionError(request.target) + + with RelayServer(respond) as server: + client = relay.RelayClient(server.base_url) + first = client.openapi() + self.assertEqual(first["kind"], "complete") + self.assertEqual(first["body"], b'{"openapi":"3.1.0"}') + self.assertEqual(first["etag"], ETAG) + second = client.openapi(etag=first["etag"]) + self.assertEqual( + second, {"kind": "not_modified", "etag": ETAG, "trace_id": second["trace_id"]} + ) + artifact = client.artifact("context") + self.assertEqual(artifact["media_type"], "application/ld+json") + self.assertIsInstance(artifact["body"], bytes) + data = client.sdmx_data( + "AGENCY", "FLOW", "1.0.0", key="KEY", format="csv" + ) + self.assertEqual(data["body"], b"DATAFLOW,OBS_VALUE\nFLOW,1\n") + + self.assertEqual(len(server.requests), 4) + self.assertEqual(server.requests[1].headers["if-none-match"], ETAG) + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-relay-client/Cargo.toml b/crates/registry-relay-client/Cargo.toml new file mode 100644 index 000000000..bfa384da0 --- /dev/null +++ b/crates/registry-relay-client/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "registry-relay-client" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Canonical Rust SDK for the Registry Relay V2 HTTP API." +readme = "README.md" +repository.workspace = true +publish = false + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +registry-platform-httpsec.workspace = true +registry-platform-httputil.workspace = true +registry-relay-http-contract.workspace = true +reqwest.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +url.workspace = true +zeroize.workspace = true + +[dev-dependencies] +axum.workspace = true +tokio.workspace = true diff --git a/crates/registry-relay-client/README.md b/crates/registry-relay-client/README.md new file mode 100644 index 000000000..813e61e2c --- /dev/null +++ b/crates/registry-relay-client/README.md @@ -0,0 +1,146 @@ +# Registry Relay Rust client + +`registry-relay-client` is the canonical Rust SDK for the fixed Registry Relay +V2 HTTP API. It covers process probes, discovery, consultation operations, +artifacts, and the bounded SDMX data and structure profiles without depending +on the Relay runtime. + +One method call performs at most one HTTP exchange. The client does not follow +redirects, use ambient proxies, retry, advance pagination, or fetch referenced +schemas. Callers remain responsible for deciding whether and when to repeat a +request. + +## Configure a client + +```rust +use std::sync::Arc; +use registry_relay_client::{RelayClient, RelayClientConfig, StaticToken}; +use url::Url; + +# fn build() -> Result> { +let token = Arc::new(StaticToken::new("short-lived-access-token")?); +let config = RelayClientConfig::new( + Url::parse("https://relay.example/institution-a")?, +) +.with_token_provider(token); +let client = RelayClient::new(config)?; +# Ok(client) +# } +``` + +The base URL may include a deployment prefix. Each route segment is appended +without replacing that prefix. HTTPS is required except for loopback HTTP. +Credentials, a query, a fragment, and ambiguous empty path segments are +refused at construction. + +The token provider is optional. When present, it is called once for an +auth-eligible request. `health`, `ready`, and `openapi` never call it and never +send authorization. `PrivateKeyJwt` and `StaticToken` are re-exported from the +shared platform client primitives. + +## Discovery and consultation + +```rust,no_run +use registry_relay_client::{ + CollectionRequest, Conditional, RecordFormat, RecordOptions, + ResourceListRequest, +}; +# async fn run(client: ®istry_relay_client::RelayClient) -> Result<(), registry_relay_client::RelayClientError> { +let resources = client + .resources(ResourceListRequest::default().page_size(50)?, None) + .await?; + +let options = RecordOptions::default() + .fields(["name", "status"])? + .access_profile("caseworker")? + .format(RecordFormat::JsonLd); +let request = CollectionRequest::default() + .options(options) + .page_size(25)? + .filter("status", "active")?; + +let page = client.list_records("people", &request, None).await?; +if let Conditional::Complete(complete) = page { + if let Some(next) = complete.value.continuation { + // Pagination occurs only because the caller explicitly asks for it. + let _next_page = client.continue_collection(&next, None).await?; + } +} +# let _ = resources; +# Ok(()) +# } +``` + +A collection continuation is an opaque cursor bound to its route, requested +wire format, and access profile. Its validated serializable projection supports +language bindings and persistence without admitting first-page fields, +filters, bbox, or page size. A caller cannot combine those facts with a cursor. + +Lookups serialize exactly `{"selectors": {...}}`; selector names and scalar +values are bounded before a request is built. Search filters cannot collide +with Relay's reserved query names. Field lists reject empty or duplicate names, +and bounding boxes reject non-finite coordinates, invalid latitude/longitude, +south-to-north inversion, and antimeridian crossing. + +## Conditional responses + +Cacheable operations return `Conditional`. A complete response may carry a +validated `StrongEtag`. Supply that tag to a later call to send +`If-None-Match`: + +```rust,no_run +use registry_relay_client::Conditional; +# async fn run(client: ®istry_relay_client::RelayClient) -> Result<(), registry_relay_client::RelayClientError> { +let first = client.openapi(None).await?; +if let Conditional::Complete(complete) = first { + if let Some(etag) = complete.metadata.etag() { + match client.openapi(Some(etag)).await? { + Conditional::NotModified(not_modified) => { + assert_eq!(¬_modified.etag, etag); + } + Conditional::Complete(_) => {} + } + } +} +# Ok(()) +# } +``` + +Only a strong quoted lower-case SHA-256 tag is accepted. A `304` must echo the +requested tag and have an empty bounded body. + +## SDMX and artifacts + +```rust,no_run +use registry_relay_client::{SdmxDataFormat, SdmxDataRequest}; +# async fn run(client: ®istry_relay_client::RelayClient) -> Result<(), registry_relay_client::RelayClientError> { +let request = SdmxDataRequest::new("AGENCY", "FLOW", "1.0.0")? + .constraint("TIME_PERIOD", "ge:2020+le:2024")? + .limit(500)? + .format(SdmxDataFormat::Json); +let document = client.sdmx_data(&request, None).await?; +let artifact = client.artifact("people--list-schema", None).await?; +# let _ = (document, artifact); +# Ok(()) +# } +``` + +The SDMX context is fixed to `dataflow`. Query construction percent-encodes a +literal plus as `%2B`, preserving SDMX range semantics instead of allowing +form decoding to turn it into a space. SDMX and OpenAPI media types are exact. +Artifacts preserve their single syntactically valid, bounded server-declared +media type and otherwise remain raw bytes. + +## Response security + +Before returning any body, the client enforces bounded headers and body, +exactly one canonical lower-case W3C Trace Context v0 `traceparent`, and the +route's response media type. A Relay Problem must be the exact six-member +document for one `registry-relay-http-contract::ProblemCode`, including exact +status and header/body trace equality. Only a registered `429` may expose one +numeric `Retry-After` from 1 through 60 seconds. + +Errors intentionally retain only fixed local reasons, public status/problem +codes, validated trace identifiers, and bounded retry guidance. They never +include credentials, selectors, filters, response bodies, header values, URLs, +or reqwest error chains. Raw response bytes are also omitted from `Debug`. diff --git a/crates/registry-relay-client/src/client.rs b/crates/registry-relay-client/src/client.rs new file mode 100644 index 000000000..2a1850661 --- /dev/null +++ b/crates/registry-relay-client/src/client.rs @@ -0,0 +1,823 @@ +use std::fmt; + +use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, IF_NONE_MATCH}; +use reqwest::{Method, Response, StatusCode}; +use serde::de::DeserializeOwned; + +use crate::query::encoded_query; +use crate::response::CollectionRoute; +use crate::transport::{exact_media_type, problem, response_etag, trace_id, Transport}; +use crate::*; + +const APPLICATION_JSON: &str = "application/json"; +const APPLICATION_GEO_JSON: &str = "application/geo+json"; +const SDMX_STRUCTURE_JSON: &str = "application/vnd.sdmx.structure+json;version=2.1.0"; + +/// One explicitly initiated exchange with one Relay deployment. +pub struct RelayClient { + config: RelayClientConfig, + transport: Transport, +} + +impl RelayClient { + pub fn new(config: RelayClientConfig) -> Result { + config.validate()?; + let transport = Transport::new(&config)?; + Ok(Self { config, transport }) + } + + /// Unauthenticated liveness probe. + pub async fn health(&self) -> Result, RelayClientError> { + self.probe(&["health"]).await + } + + /// Unauthenticated readiness probe. + pub async fn ready(&self) -> Result, RelayClientError> { + self.probe(&["ready"]).await + } + + /// Unauthenticated public OpenAPI artifact. + pub async fn openapi( + &self, + etag: Option<&StrongEtag>, + ) -> Result, RelayClientError> { + let wire = self + .get( + &["openapi.json"], + &[], + APPLICATION_JSON, + etag, + Credential::None, + ) + .await?; + decode_raw_conditional(wire) + } + + pub async fn service_metadata( + &self, + etag: Option<&StrongEtag>, + ) -> Result, RelayClientError> { + let wire = self + .get(&["v2"], &[], APPLICATION_JSON, etag, Credential::Optional) + .await?; + decode_json_conditional(wire, APPLICATION_JSON) + } + + pub async fn resources( + &self, + request: ResourceListRequest, + etag: Option<&StrongEtag>, + ) -> Result>, RelayClientError> { + self.resource_page(request.pairs(), etag).await + } + + pub async fn continue_resources( + &self, + continuation: &ResourceContinuation, + etag: Option<&StrongEtag>, + ) -> Result>, RelayClientError> { + self.resource_page(vec![("cursor".into(), continuation.cursor.clone())], etag) + .await + } + + pub async fn resource( + &self, + resource: &str, + etag: Option<&StrongEtag>, + ) -> Result, RelayClientError> { + validate_route_identifier(resource)?; + let wire = self + .get( + &["v2", "resources", resource], + &[], + APPLICATION_JSON, + etag, + Credential::Optional, + ) + .await?; + decode_json_conditional(wire, APPLICATION_JSON) + } + + pub async fn list_records( + &self, + resource: &str, + request: &CollectionRequest, + etag: Option<&StrongEtag>, + ) -> Result>, RelayClientError> { + validate_route_identifier(resource)?; + let route = CollectionRoute::Records { + resource: resource.to_owned(), + }; + self.collection_page(route, request.pairs()?, request.options.clone(), etag) + .await + } + + pub async fn search_records( + &self, + resource: &str, + search: &str, + request: &CollectionRequest, + etag: Option<&StrongEtag>, + ) -> Result>, RelayClientError> { + validate_route_identifier(resource)?; + validate_route_identifier(search)?; + let route = CollectionRoute::Search { + resource: resource.to_owned(), + search: search.to_owned(), + }; + self.collection_page(route, request.pairs()?, request.options.clone(), etag) + .await + } + + /// Advance exactly one page when the caller explicitly supplies a continuation. + pub async fn continue_collection( + &self, + continuation: &CollectionContinuation, + etag: Option<&StrongEtag>, + ) -> Result>, RelayClientError> { + let mut pairs = vec![("cursor".into(), continuation.cursor.clone())]; + if let Some(access_profile) = &continuation.access_profile { + pairs.push(("accessProfile".into(), access_profile.clone())); + } + let options = RecordOptions { + fields: Vec::new(), + access_profile: continuation.access_profile.clone(), + format: continuation.format, + }; + self.collection_page(continuation.route.clone(), pairs, options, etag) + .await + } + + pub async fn read_record( + &self, + resource: &str, + record_identifier: &str, + options: &RecordOptions, + etag: Option<&StrongEtag>, + ) -> Result, RelayClientError> { + validate_route_identifier(resource)?; + validate_record_identifier(record_identifier)?; + let mut pairs = Vec::new(); + options.append_query(&mut pairs); + let wire = self + .get( + &["v2", "resources", resource, "records", record_identifier], + &pairs, + options.format.media_type(), + etag, + Credential::Optional, + ) + .await?; + decode_record_conditional(wire, options.format) + } + + pub async fn lookup_record( + &self, + resource: &str, + lookup: &str, + request: &LookupRequest, + etag: Option<&StrongEtag>, + ) -> Result, RelayClientError> { + validate_route_identifier(resource)?; + validate_route_identifier(lookup)?; + let mut pairs = Vec::new(); + request.options.append_query(&mut pairs); + let url = self.url_with_query(&["v2", "resources", resource, "lookups", lookup], &pairs)?; + let mut builder = self + .transport + .http + .request(Method::POST, url) + .header(ACCEPT, request.options.format.media_type()) + .header(CONTENT_TYPE, APPLICATION_JSON) + .body(request.body()?); + builder = self.authorize(builder, Credential::Optional).await?; + if let Some(etag) = etag { + builder = builder.header(IF_NONE_MATCH, etag.as_str()); + } + let response = self.transport.send(builder).await?; + let wire = self + .wire(response, Some(request.options.format.media_type()), etag) + .await?; + decode_record_conditional(wire, request.options.format) + } + + /// Retrieve an artifact and preserve its single bounded server media type. + pub async fn artifact( + &self, + artifact_identifier: &str, + etag: Option<&StrongEtag>, + ) -> Result, RelayClientError> { + validate_artifact_identifier(artifact_identifier)?; + let url = self.url_with_query(&["v2", "artifacts", artifact_identifier], &[])?; + let mut builder = self.transport.http.get(url).header(ACCEPT, "*/*"); + builder = self.authorize(builder, Credential::Optional).await?; + if let Some(etag) = etag { + builder = builder.header(IF_NONE_MATCH, etag.as_str()); + } + let response = self.transport.send(builder).await?; + let wire = self.wire(response, None, etag).await?; + decode_raw_conditional(wire) + } + + pub async fn sdmx_data( + &self, + request: &SdmxDataRequest, + etag: Option<&StrongEtag>, + ) -> Result, RelayClientError> { + let pairs = request.pairs()?; + let mut segments = vec![ + "sdmx", + "v2", + "data", + "dataflow", + request.agency.as_str(), + request.resource.as_str(), + request.version.as_str(), + ]; + if let Some(key) = &request.key { + segments.push(key); + } + let wire = self + .get( + &segments, + &pairs, + request.format.media_type(), + etag, + Credential::Optional, + ) + .await?; + decode_raw_conditional(wire) + } + + pub async fn sdmx_structure( + &self, + request: &SdmxStructureRequest, + etag: Option<&StrongEtag>, + ) -> Result, RelayClientError> { + let wire = self + .get( + &[ + "sdmx", + "v2", + "structure", + request.kind.path(), + &request.agency, + &request.resource, + &request.version, + ], + &[("references".into(), "none".into())], + SDMX_STRUCTURE_JSON, + etag, + Credential::Optional, + ) + .await?; + decode_raw_conditional(wire) + } + + async fn probe(&self, segments: &[&str]) -> Result, RelayClientError> { + let wire = self + .get(segments, &[], APPLICATION_JSON, None, Credential::None) + .await?; + match decode_json_conditional::(wire, APPLICATION_JSON)? { + Conditional::Complete(value) => Ok(value), + Conditional::NotModified(_) => Err(RelayClientError::protocol( + 304, + ProtocolFailure::Status, + None, + )), + } + } + + async fn resource_page( + &self, + pairs: Vec<(String, String)>, + etag: Option<&StrongEtag>, + ) -> Result>, RelayClientError> { + let wire = self + .get( + &["v2", "resources"], + &pairs, + APPLICATION_JSON, + etag, + Credential::Optional, + ) + .await?; + match decode_json_conditional::(wire, APPLICATION_JSON)? { + Conditional::NotModified(value) => Ok(Conditional::NotModified(value)), + Conditional::Complete(Complete { value, metadata }) => { + let continuation = value + .page_info + .next_cursor + .as_ref() + .map(|cursor| ResourceContinuation::try_from_cursor(cursor.clone())) + .transpose() + .map_err(|_| { + RelayClientError::protocol( + 200, + ProtocolFailure::Body, + Some(metadata.trace_id().clone()), + ) + })?; + Ok(Conditional::Complete(Complete { + value: ResourcePage { + value, + continuation, + }, + metadata, + })) + } + } + } + + async fn collection_page( + &self, + route: CollectionRoute, + pairs: Vec<(String, String)>, + options: RecordOptions, + etag: Option<&StrongEtag>, + ) -> Result>, RelayClientError> { + let segments = match &route { + CollectionRoute::Records { resource } => { + vec!["v2", "resources", resource.as_str(), "records"] + } + CollectionRoute::Search { resource, search } => vec![ + "v2", + "resources", + resource.as_str(), + "searches", + search.as_str(), + ], + }; + let wire = self + .get( + &segments, + &pairs, + options.format.media_type(), + etag, + Credential::Optional, + ) + .await?; + match decode_collection_conditional(wire, options.format)? { + Conditional::NotModified(value) => Ok(Conditional::NotModified(value)), + Conditional::Complete(Complete { value, metadata }) => { + let cursor = match &value { + RecordCollectionResponse::Json(value) => value.page_info.next_cursor.as_ref(), + RecordCollectionResponse::GeoJson(value) => { + value.page_info.next_cursor.as_ref() + } + }; + let continuation = cursor + .map(|cursor| { + let route = match &route { + CollectionRoute::Records { resource } => { + CollectionRouteProjection::Records { + resource: resource.clone(), + } + } + CollectionRoute::Search { resource, search } => { + CollectionRouteProjection::Search { + resource: resource.clone(), + search: search.clone(), + } + } + }; + CollectionContinuation::try_from_projection( + CollectionContinuationProjection { + route, + cursor: cursor.clone(), + format: options.format, + access_profile: options.access_profile.clone(), + }, + ) + }) + .transpose() + .map_err(|_| { + RelayClientError::protocol( + 200, + ProtocolFailure::Body, + Some(metadata.trace_id().clone()), + ) + })?; + Ok(Conditional::Complete(Complete { + value: CollectionPage { + value, + continuation, + }, + metadata, + })) + } + } + } + + async fn get( + &self, + segments: &[&str], + pairs: &[(String, String)], + accept: &str, + etag: Option<&StrongEtag>, + credential: Credential, + ) -> Result { + let url = self.url_with_query(segments, pairs)?; + let mut builder = self.transport.http.get(url).header(ACCEPT, accept); + builder = self.authorize(builder, credential).await?; + if let Some(etag) = etag { + builder = builder.header(IF_NONE_MATCH, etag.as_str()); + } + let response = self.transport.send(builder).await?; + self.wire(response, Some(accept), etag).await + } + + async fn authorize( + &self, + mut builder: reqwest::RequestBuilder, + credential: Credential, + ) -> Result { + if matches!(credential, Credential::Optional) { + if let Some(provider) = &self.config.token_provider { + let token = provider.bearer_token().await?; + builder = builder.header(AUTHORIZATION, token.authorization_header_value()); + } + } + Ok(builder) + } + + fn url_with_query( + &self, + segments: &[&str], + pairs: &[(String, String)], + ) -> Result { + let mut url = self.transport.url(segments)?; + if !pairs.is_empty() { + url.set_query(Some(&encoded_query(pairs))); + } + if url.as_str().len() > 16 * 1024 { + return Err(RelayClientError::invalid_request( + "the request URI exceeds the client bound", + )); + } + Ok(url) + } + + async fn wire( + &self, + response: Response, + expected_media: Option<&str>, + conditional: Option<&StrongEtag>, + ) -> Result { + let status = response.status(); + if status != StatusCode::OK && status != StatusCode::NOT_MODIFIED { + return Err(problem(response, &self.transport).await); + } + let headers = response.headers().clone(); + let trace = trace_id(status, &headers)?; + let etag = response_etag(status, &headers)?; + if status == StatusCode::NOT_MODIFIED { + let Some(expected) = conditional else { + return Err(RelayClientError::protocol( + status.as_u16(), + ProtocolFailure::Status, + Some(trace), + )); + }; + let Some(actual) = etag else { + return Err(RelayClientError::protocol( + status.as_u16(), + ProtocolFailure::EntityTag, + Some(trace), + )); + }; + if &actual != expected { + return Err(RelayClientError::protocol( + status.as_u16(), + ProtocolFailure::EntityTag, + Some(trace), + )); + } + let body = self.transport.read(response, 1).await?; + return not_modified_outcome(actual, trace, &body); + } + let media_type = match expected_media { + Some(expected) if exact_media_type(&headers, expected) => expected.to_owned(), + Some(_) => { + return Err(RelayClientError::protocol( + status.as_u16(), + ProtocolFailure::MediaType, + Some(trace), + )) + } + None => response_media_type(&headers).map_err(|_| { + RelayClientError::protocol( + status.as_u16(), + ProtocolFailure::MediaType, + Some(trace.clone()), + ) + })?, + }; + let body = self + .transport + .read(response, self.config.max_response_bytes) + .await?; + Ok(WireOutcome::Complete { + body, + metadata: ResponseMetadata::new(trace, etag), + media_type, + }) + } +} + +fn not_modified_outcome( + etag: StrongEtag, + trace_id: registry_platform_httpsec::TraceId, + body: &[u8], +) -> Result { + if !body.is_empty() { + return Err(RelayClientError::protocol( + StatusCode::NOT_MODIFIED.as_u16(), + ProtocolFailure::NotModifiedBody, + Some(trace_id), + )); + } + Ok(WireOutcome::NotModified(NotModified { etag, trace_id })) +} + +impl fmt::Debug for RelayClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RelayClient") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Copy)] +enum Credential { + None, + Optional, +} + +enum WireOutcome { + Complete { + body: Vec, + metadata: ResponseMetadata, + media_type: String, + }, + NotModified(NotModified), +} + +fn decode_json_conditional( + wire: WireOutcome, + _media: &str, +) -> Result, RelayClientError> { + match wire { + WireOutcome::NotModified(value) => Ok(Conditional::NotModified(value)), + WireOutcome::Complete { body, metadata, .. } => { + let value = serde_json::from_slice(&body).map_err(|_| { + RelayClientError::protocol( + 200, + ProtocolFailure::Body, + Some(metadata.trace_id().clone()), + ) + })?; + Ok(Conditional::Complete(Complete { value, metadata })) + } + } +} + +fn decode_raw_conditional(wire: WireOutcome) -> Result, RelayClientError> { + Ok(match wire { + WireOutcome::NotModified(value) => Conditional::NotModified(value), + WireOutcome::Complete { + body, + metadata, + media_type, + } => Conditional::Complete(Complete { + value: RawDocument::new(media_type, body), + metadata, + }), + }) +} + +fn decode_record_conditional( + wire: WireOutcome, + format: RecordFormat, +) -> Result, RelayClientError> { + match format { + RecordFormat::Json | RecordFormat::JsonLd => { + decode_json_conditional::(wire, format.media_type()) + .map(|value| map_conditional(value, RecordResponse::Json)) + } + RecordFormat::GeoJsonRfc7946 | RecordFormat::JsonFg => { + decode_json_conditional::(wire, APPLICATION_GEO_JSON) + .map(|value| map_conditional(value, RecordResponse::GeoJson)) + } + } +} + +fn decode_collection_conditional( + wire: WireOutcome, + format: RecordFormat, +) -> Result, RelayClientError> { + match format { + RecordFormat::Json | RecordFormat::JsonLd => { + decode_json_conditional::(wire, format.media_type()) + .map(|value| map_conditional(value, RecordCollectionResponse::Json)) + } + RecordFormat::GeoJsonRfc7946 | RecordFormat::JsonFg => { + decode_json_conditional::(wire, APPLICATION_GEO_JSON) + .map(|value| map_conditional(value, RecordCollectionResponse::GeoJson)) + } + } +} + +fn map_conditional(value: Conditional, map: impl FnOnce(T) -> U) -> Conditional { + match value { + Conditional::Complete(Complete { value, metadata }) => Conditional::Complete(Complete { + value: map(value), + metadata, + }), + Conditional::NotModified(value) => Conditional::NotModified(value), + } +} + +fn validate_route_identifier(value: &str) -> Result<(), RelayClientError> { + if value.is_empty() + || value.len() > 128 + || value.starts_with('-') + || value.ends_with('-') + || value.contains("--") + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(RelayClientError::invalid_request( + "a route identifier is invalid", + )); + } + Ok(()) +} + +fn validate_record_identifier(value: &str) -> Result<(), RelayClientError> { + if value.is_empty() + || value.len() > 512 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')) + { + return Err(RelayClientError::invalid_request( + "the record identifier is invalid", + )); + } + Ok(()) +} + +fn validate_artifact_identifier(value: &str) -> Result<(), RelayClientError> { + if value.is_empty() + || value.len() > 512 + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'.') + }) + { + return Err(RelayClientError::invalid_request( + "the artifact identifier is invalid", + )); + } + Ok(()) +} + +fn response_media_type(headers: &reqwest::header::HeaderMap) -> Result { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + let value = match (values.next(), values.next()) { + (Some(value), None) => value.to_str().map_err(|_| ())?, + _ => return Err(()), + }; + let mut parts = value.split(';'); + let essence = parts.next().ok_or(())?.trim(); + let Some((kind, subtype)) = essence.split_once('/') else { + return Err(()); + }; + if subtype.contains('/') || !media_token(kind) || !media_token(subtype) { + return Err(()); + } + for parameter in parts { + let Some((name, parameter_value)) = parameter.trim().split_once('=') else { + return Err(()); + }; + if !media_token(name) + || !(media_token(parameter_value) || quoted_media_parameter(parameter_value)) + { + return Err(()); + } + } + Ok(value.to_owned()) +} + +fn media_token(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +fn quoted_media_parameter(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() < 2 || bytes.first() != Some(&b'"') || bytes.last() != Some(&b'"') { + return false; + } + let mut escaped = false; + for byte in &bytes[1..bytes.len() - 1] { + if escaped { + if byte.is_ascii_control() { + return false; + } + escaped = false; + } else if *byte == b'\\' { + escaped = true; + } else if *byte == b'"' || byte.is_ascii_control() { + return false; + } + } + !escaped +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::Response as HttpResponse; + use url::Url; + + const TRACEPARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + const ETAG: &str = "\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\""; + + fn not_modified_response(etag: Option<&str>, body: &[u8]) -> Response { + let mut builder = HttpResponse::builder() + .status(StatusCode::NOT_MODIFIED) + .header("traceparent", TRACEPARENT) + .header(CONTENT_TYPE, APPLICATION_JSON); + if let Some(etag) = etag { + builder = builder.header("etag", etag); + } + builder + .body(reqwest::Body::from(body.to_vec())) + .expect("test response") + .into() + } + + #[tokio::test] + async fn not_modified_requires_matching_strong_sha256_etag_and_empty_body() { + let client = RelayClient::new(RelayClientConfig::new( + Url::parse("http://127.0.0.1:1/prefix").expect("base URL"), + )) + .expect("client"); + let expected = StrongEtag::parse(ETAG).expect("etag"); + + assert!(matches!( + client + .wire( + not_modified_response(Some(ETAG), b""), + Some(APPLICATION_JSON), + Some(&expected) + ) + .await, + Ok(WireOutcome::NotModified(_)) + )); + + for response in [ + not_modified_response(None, b""), + not_modified_response( + Some("\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\""), + b"", + ), + ] { + assert!(matches!( + client + .wire(response, Some(APPLICATION_JSON), Some(&expected)) + .await, + Err(RelayClientError::Protocol { .. }) + )); + } + + let trace = registry_platform_httpsec::TraceId::parse("4bf92f3577b34da6a3ce929d0e0e4736") + .expect("trace ID"); + assert!(matches!( + not_modified_outcome(expected, trace, b"must-not-be-present"), + Err(RelayClientError::Protocol { .. }) + )); + } +} diff --git a/crates/registry-relay-client/src/config.rs b/crates/registry-relay-client/src/config.rs new file mode 100644 index 000000000..6accb09af --- /dev/null +++ b/crates/registry-relay-client/src/config.rs @@ -0,0 +1,144 @@ +use std::{fmt, sync::Arc, time::Duration}; + +use registry_platform_httputil::client::{ServiceBaseUrl, TokenProvider}; +use url::Url; +use zeroize::Zeroizing; + +use crate::RelayClientError; + +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +pub const DEFAULT_MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; + +/// All transport policy for one Relay deployment. +pub struct RelayClientConfig { + pub(crate) base_url: Url, + pub(crate) token_provider: Option>, + pub(crate) request_timeout: Duration, + pub(crate) connect_timeout: Duration, + pub(crate) max_response_bytes: u64, + pub(crate) user_agent: Option, + pub(crate) trusted_root_certificates: Option>>, +} + +impl RelayClientConfig { + #[must_use] + pub fn new(base_url: Url) -> Self { + Self { + base_url, + token_provider: None, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + connect_timeout: DEFAULT_CONNECT_TIMEOUT, + max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, + user_agent: None, + trusted_root_certificates: None, + } + } + + #[must_use] + pub fn with_token_provider(mut self, provider: Arc) -> Self { + self.token_provider = Some(provider); + self + } + + #[must_use] + pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + #[must_use] + pub fn with_connect_timeout(mut self, timeout: Duration) -> Self { + self.connect_timeout = timeout; + self + } + + #[must_use] + pub fn with_max_response_bytes(mut self, maximum: u64) -> Self { + self.max_response_bytes = maximum; + self + } + + #[must_use] + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + /// Trust exactly this PEM certificate bundle instead of platform roots. + #[must_use] + pub fn with_trusted_root_certificates(mut self, pem: impl Into>) -> Self { + self.trusted_root_certificates = Some(Zeroizing::new(pem.into())); + self + } + + #[must_use] + pub fn base_url(&self) -> &Url { + &self.base_url + } + + pub(crate) fn validate(&self) -> Result<(), RelayClientError> { + ServiceBaseUrl::new(self.base_url.clone()).map_err(|_| { + RelayClientError::configuration( + "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() { + return Err(RelayClientError::configuration( + "request and connection timeouts must be greater than zero", + )); + } + if self.max_response_bytes == 0 { + return Err(RelayClientError::configuration( + "the response body bound must be greater than zero", + )); + } + Ok(()) + } +} + +impl fmt::Debug for RelayClientConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RelayClientConfig") + .field("base_url", &"") + .field("token_provider", &self.token_provider.is_some()) + .field("request_timeout", &self.request_timeout) + .field("connect_timeout", &self.connect_timeout) + .field("max_response_bytes", &self.max_response_bytes) + .field("user_agent", &self.user_agent.is_some()) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn configuration_debug_withholds_urls_and_certificate_material() { + let config = RelayClientConfig::new( + Url::parse("https://sensitive-host-canary.invalid/private-prefix") + .expect("fixture URL"), + ) + .with_user_agent("sensitive-user-agent-canary") + .with_trusted_root_certificates(b"certificate-material-canary".to_vec()); + let rendered = format!("{config:?}"); + assert!(!rendered.contains("sensitive-host-canary")); + assert!(!rendered.contains("private-prefix")); + assert!(!rendered.contains("certificate-material-canary")); + assert!(!rendered.contains("sensitive-user-agent-canary")); + } + + #[test] + fn unsafe_base_url_errors_do_not_render_sensitive_userinfo() { + let config = RelayClientConfig::new( + Url::parse("https://secret-user:secret-password@example.invalid/") + .expect("fixture URL"), + ); + let error = config.validate().expect_err("userinfo is refused"); + let rendered = format!("{error:?} {error}"); + assert!(!rendered.contains("secret-user")); + assert!(!rendered.contains("secret-password")); + } +} diff --git a/crates/registry-relay-client/src/error.rs b/crates/registry-relay-client/src/error.rs new file mode 100644 index 000000000..aa9f8f84d --- /dev/null +++ b/crates/registry-relay-client/src/error.rs @@ -0,0 +1,136 @@ +use registry_platform_httpsec::TraceId; +use registry_platform_httputil::client::TokenError; +use registry_relay_http_contract::ProblemCode; +use thiserror::Error; + +pub use registry_platform_httputil::client::TransportKind; + +/// A closed reason why a response could not be accepted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ProtocolFailure { + HeaderBounds, + TraceContext, + MediaType, + Body, + Problem, + EntityTag, + NotModifiedBody, + Status, +} + +impl std::fmt::Display for ProtocolFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(match self { + Self::HeaderBounds => "response headers exceeded the accepted bounds", + Self::TraceContext => "response trace context was not canonical", + Self::MediaType => "response media type was not the requested type", + Self::Body => "response body did not match the expected shape", + Self::Problem => "problem response did not match the registered Relay problem", + Self::EntityTag => "response entity tag was not a strong SHA-256 tag", + Self::NotModifiedBody => "not-modified response carried a body", + Self::Status => "response status was not valid for this operation", + }) + } +} + +/// Coarse, value-free failures from a Relay exchange. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum RelayClientError { + #[error("the Relay client cannot be used as configured: {reason}")] + Configuration { reason: &'static str }, + #[error("the Relay request is invalid: {reason}")] + InvalidRequest { reason: &'static str }, + #[error(transparent)] + Token(#[from] TokenError), + #[error("the Relay exchange did not complete: {kind}")] + Transport { kind: TransportKind }, + #[error("Relay refused or failed the request: status {status}, code {code}")] + Problem { + status: u16, + code: ProblemCode, + trace_id: TraceId, + retry_after_seconds: Option, + }, + #[error("the Relay response did not satisfy its wire contract: status {status}, {failure}")] + Protocol { + status: u16, + failure: ProtocolFailure, + trace_id: Option, + }, +} + +impl RelayClientError { + pub(crate) const fn configuration(reason: &'static str) -> Self { + Self::Configuration { reason } + } + + pub(crate) const fn transport(kind: TransportKind) -> Self { + Self::Transport { kind } + } + + pub(crate) const fn invalid_request(reason: &'static str) -> Self { + Self::InvalidRequest { reason } + } + + pub(crate) fn protocol( + status: u16, + failure: ProtocolFailure, + trace_id: Option, + ) -> Self { + Self::Protocol { + status, + failure, + trace_id, + } + } + + #[must_use] + pub fn trace_id(&self) -> Option<&TraceId> { + match self { + Self::Problem { trace_id, .. } => Some(trace_id), + Self::Protocol { trace_id, .. } => trace_id.as_ref(), + _ => None, + } + } + + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Configuration { .. } => "configuration", + Self::InvalidRequest { .. } => "invalid_request", + Self::Token(_) => "token", + Self::Transport { .. } => "transport", + Self::Problem { .. } => "problem", + Self::Protocol { .. } => "protocol", + } + } + + #[must_use] + pub fn status(&self) -> Option { + match self { + Self::Problem { status, .. } | Self::Protocol { status, .. } => Some(*status), + _ => None, + } + } + + #[must_use] + pub fn problem_code(&self) -> Option { + match self { + Self::Problem { code, .. } => Some(*code), + _ => None, + } + } + + #[must_use] + pub fn retry_after_seconds(&self) -> Option { + match self { + Self::Problem { + retry_after_seconds, + .. + } => *retry_after_seconds, + _ => None, + } + } +} diff --git a/crates/registry-relay-client/src/lib.rs b/crates/registry-relay-client/src/lib.rs new file mode 100644 index 000000000..4e7e8ff43 --- /dev/null +++ b/crates/registry-relay-client/src/lib.rs @@ -0,0 +1,26 @@ +//! Canonical, bounded client for the fixed Registry Relay V2 HTTP surface. +//! +//! One method performs one exchange. The crate never follows redirects, uses an +//! ambient proxy, retries, fetches schemas, or advances a collection on its own. + +mod client; +mod config; +mod error; +mod model; +mod query; +mod response; +mod transport; + +pub use client::RelayClient; +pub use config::{ + RelayClientConfig, DEFAULT_CONNECT_TIMEOUT, DEFAULT_MAX_RESPONSE_BYTES, DEFAULT_REQUEST_TIMEOUT, +}; +pub use error::{ProtocolFailure, RelayClientError, TransportKind}; +pub use model::*; +pub use query::*; +pub use registry_platform_httputil::client::{ + BearerToken, PrivateKeyJwt, PrivateKeyJwtConfig, StaticToken, TokenError, TokenProvider, + MAXIMUM_TRUSTED_ROOT_CERTIFICATE_BUNDLE_BYTES, +}; +pub use registry_relay_http_contract::ProblemCode; +pub use response::*; diff --git a/crates/registry-relay-client/src/model.rs b/crates/registry-relay-client/src/model.rs new file mode 100644 index 000000000..dd1de8c9c --- /dev/null +++ b/crates/registry-relay-client/src/model.rs @@ -0,0 +1,368 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ProbeStatus { + pub status: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ServiceMetadata { + pub registry_identifier: String, + pub name: String, + pub authority: Institution, + pub operator: Option, + pub authoritative_scope: String, + pub product: Product, + pub api_binding: ApiBinding, + pub alignment_targets: Vec, + pub capabilities: Vec, + pub links: ServiceLinks, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Institution { + pub identifier: String, + pub name: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Product { + pub name: String, + pub version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ApiBinding { + pub name: String, + pub version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AlignmentTarget { + pub name: String, + pub version: String, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cfr_target: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ServiceLinks { + #[serde(rename = "self")] + pub self_: String, + pub resources: String, + pub openapi: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(tag = "family")] +pub enum Capability { + #[serde(rename = "consultation")] + Consultation(ConsultationCapability), + #[serde(rename = "aggregate-data")] + AggregateData(AggregateDataCapability), +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ConsultationCapability { + pub pattern: String, + pub resource_identifier: String, + pub operation_identifier: String, + pub access_profile_identifier: String, + pub is_default: bool, + pub disclosure_profile: String, + pub schema_reference: String, + pub semantic_model_reference: String, + pub context_reference: String, + pub href: String, + pub wire_formats: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub spatial_query: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub classification_reference: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub processing_reference: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct WireFormatCapability { + pub id: String, + pub media_type: String, + #[serde(default)] + pub format_profiles: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FormatProfileCapability { + pub id: String, + pub uri: String, + pub crs: String, + #[serde(default)] + pub conforms_to: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct SpatialQueryCapability { + pub bbox: BboxCapability, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct BboxCapability { + pub crs: String, + pub predicate: String, + pub maximum_longitude_span_degrees: f64, + pub maximum_latitude_span_degrees: f64, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct AggregateDataCapability { + pub pattern: String, + pub statistical_dataset_identifier: String, + pub operation_identifier: String, + pub profile: SdmxProfile, + pub wire_formats: Vec, + pub href: String, + pub structure_links: SdmxStructureLinks, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SdmxProfile { + pub sdmx_rest_version: String, + pub sdmx_data_json_version: String, + pub sdmx_data_csv_version: String, + pub sdmx_structure_json_version: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct SdmxWireFormat { + pub id: String, + pub media_type: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SdmxStructureLinks { + pub dataflow: String, + pub datastructure: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResourceDocument { + pub resource_identifier: String, + pub title: String, + pub description: String, + pub semantic_class: String, + pub enumeration_posture: String, + pub capabilities: Vec, + pub links: ResourceLinks, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct ResourceLinks { + #[serde(rename = "self")] + pub self_: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResourceCollection { + pub items: Vec, + pub page_info: CursorPageInfo, + pub meta: RegistryMetadata, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ResourceEnvelope { + pub data: ResourceDocument, + pub meta: RegistryMetadata, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RegistryMetadata { + pub registry_identifier: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CursorPageInfo { + pub next_cursor: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct Record { + pub registry_identifier: String, + pub record_identifier: String, + pub revision_identifier: String, + pub lifecycle_state: String, + pub schema_reference: String, + pub semantic_model_reference: String, + pub authority_identifier: String, + pub recorded_at: String, + pub domain_data: BTreeMap, + #[serde(rename = "@id")] + #[serde(skip_serializing_if = "Option::is_none")] + pub json_ld_id: Option, + #[serde(rename = "@type")] + #[serde(skip_serializing_if = "Option::is_none")] + pub json_ld_type: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RecordMetadata { + pub operation_identifier: String, + pub access_profile: String, + pub family: String, + pub pattern: String, + pub disclosure_profile: String, + pub contract_revision: String, + pub source_revision: SourceRevision, + pub selected_fields: Vec, + pub links: RecordLinks, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct SourceRevision { + pub profile: String, + pub status: String, + pub value: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RecordLinks { + #[serde(rename = "self")] + pub self_: String, + pub context: String, + pub schema: String, + #[serde(rename = "semanticModel")] + pub semantic_model: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RecordEnvelope { + pub data: Record, + pub meta: RecordMetadata, + #[serde(rename = "@context")] + #[serde(skip_serializing_if = "Option::is_none")] + pub json_ld_context: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct RecordCollection { + pub items: Vec, + pub page_info: CursorPageInfo, + pub meta: RecordMetadata, + #[serde(rename = "@context")] + #[serde(skip_serializing_if = "Option::is_none")] + pub json_ld_context: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GeoJsonFeature { + #[serde(rename = "type")] + pub kind: String, + pub id: String, + pub geometry: Value, + pub properties: Record, + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conforms_to: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub feature_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub coord_ref_sys: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct GeoJsonFeatureCollection { + #[serde(rename = "type")] + pub kind: String, + pub features: Vec, + pub page_info: CursorPageInfo, + pub meta: RecordMetadata, + #[serde(skip_serializing_if = "Option::is_none")] + pub conforms_to: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub feature_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub coord_ref_sys: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(untagged)] +pub enum RecordResponse { + Json(RecordEnvelope), + GeoJson(GeoJsonFeature), +} + +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(untagged)] +pub enum RecordCollectionResponse { + Json(RecordCollection), + GeoJson(GeoJsonFeatureCollection), +} + +/// Lookup selectors are intentionally dynamic, but the outer body is exact. +#[derive(Clone, Debug, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct LookupBody<'a> { + pub selectors: &'a BTreeMap, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fixed_envelopes_reject_unknown_members_but_domain_records_remain_dynamic() { + let probe = serde_json::from_value::(serde_json::json!({ + "status": "ok", + "canary": "must be refused" + })); + assert!(probe.is_err()); + + let record = serde_json::from_value::(serde_json::json!({ + "registryIdentifier": "registry", + "recordIdentifier": "record-1", + "revisionIdentifier": "revision-1", + "lifecycleState": "active", + "schemaReference": "https://example.invalid/schema", + "semanticModelReference": "https://example.invalid/semantics", + "authorityIdentifier": "authority", + "recordedAt": "2026-08-11T00:00:00Z", + "domainData": {"adopterOwnedNestedShape": {"answer": 42}} + })) + .expect("dynamic domain data is retained"); + assert_eq!(record.domain_data["adopterOwnedNestedShape"]["answer"], 42); + } +} diff --git a/crates/registry-relay-client/src/query.rs b/crates/registry-relay-client/src/query.rs new file mode 100644 index 000000000..6cdfa2023 --- /dev/null +++ b/crates/registry-relay-client/src/query.rs @@ -0,0 +1,674 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::RelayClientError; + +const MAX_IDENTIFIER_BYTES: usize = 128; +const MAX_QUERY_BYTES: usize = 16 * 1024; +const MAX_VALUE_BYTES: usize = 4 * 1024; +const MAX_LOOKUP_BODY_BYTES: usize = 1024 * 1024; +const RESERVED: &[&str] = &[ + "pageSize", + "cursor", + "fields", + "accessProfile", + "bbox", + "formatProfile", +]; + +/// First-page facts for resource discovery. Continuations use a distinct, +/// opaque type and therefore cannot be mixed with `pageSize`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ResourceListRequest { + pub(crate) page_size: Option, +} + +impl ResourceListRequest { + pub fn page_size(mut self, value: u32) -> Result { + if !(1..=100).contains(&value) { + return Err(RelayClientError::invalid_request( + "resource page size must be between 1 and 100", + )); + } + self.page_size = Some(value); + Ok(self) + } + + pub(crate) fn pairs(self) -> Vec<(String, String)> { + self.page_size + .map(|value| vec![("pageSize".into(), value.to_string())]) + .unwrap_or_default() + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum RecordFormat { + #[default] + #[serde(rename = "json")] + Json, + #[serde(rename = "json-ld")] + JsonLd, + #[serde(rename = "geojson-rfc7946")] + GeoJsonRfc7946, + #[serde(rename = "json-fg")] + JsonFg, +} + +impl RecordFormat { + pub(crate) const fn media_type(self) -> &'static str { + match self { + Self::Json => "application/json", + Self::JsonLd => "application/ld+json", + Self::GeoJsonRfc7946 | Self::JsonFg => "application/geo+json", + } + } + + pub(crate) const fn profile(self) -> Option<&'static str> { + match self { + Self::GeoJsonRfc7946 => Some("rfc7946"), + Self::JsonFg => Some("jsonfg"), + Self::Json | Self::JsonLd => None, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct RecordOptions { + pub(crate) fields: Vec, + pub(crate) access_profile: Option, + pub(crate) format: RecordFormat, +} + +impl RecordOptions { + pub fn fields( + mut self, + fields: impl IntoIterator>, + ) -> Result { + self.fields = fields.into_iter().map(Into::into).collect(); + validate_fields(&self.fields)?; + Ok(self) + } + + pub fn access_profile(mut self, value: impl Into) -> Result { + let value = value.into(); + validate_identifier(&value, "the access profile identifier is invalid")?; + self.access_profile = Some(value); + Ok(self) + } + + #[must_use] + pub fn format(mut self, value: RecordFormat) -> Self { + self.format = value; + self + } + + pub(crate) fn append_query(&self, pairs: &mut Vec<(String, String)>) { + if !self.fields.is_empty() { + pairs.push(("fields".into(), self.fields.join(","))); + } + if let Some(value) = &self.access_profile { + pairs.push(("accessProfile".into(), value.clone())); + } + if let Some(value) = self.format.profile() { + pairs.push(("formatProfile".into(), value.into())); + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BoundingBox { + west: f64, + south: f64, + east: f64, + north: f64, +} + +impl BoundingBox { + pub fn new(west: f64, south: f64, east: f64, north: f64) -> Result { + if [west, south, east, north] + .iter() + .any(|value| !value.is_finite()) + || !(-180.0..=180.0).contains(&west) + || !(-180.0..=180.0).contains(&east) + || !(-90.0..=90.0).contains(&south) + || !(-90.0..=90.0).contains(&north) + || west > east + || south > north + { + return Err(RelayClientError::invalid_request( + "the bounding box is invalid", + )); + } + Ok(Self { + west, + south, + east, + north, + }) + } + + fn text(self) -> String { + format!("{},{},{},{}", self.west, self.south, self.east, self.north) + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CollectionRequest { + pub(crate) options: RecordOptions, + pub(crate) page_size: Option, + pub(crate) filters: BTreeMap, + pub(crate) bbox: Option, +} + +impl CollectionRequest { + #[must_use] + pub fn options(mut self, options: RecordOptions) -> Self { + self.options = options; + self + } + + pub fn page_size(mut self, value: u32) -> Result { + if value == 0 { + return Err(RelayClientError::invalid_request( + "page size must be greater than zero", + )); + } + self.page_size = Some(value); + Ok(self) + } + + pub fn filter( + mut self, + name: impl Into, + value: impl Into, + ) -> Result { + let name = name.into(); + let value = value.into(); + validate_identifier(&name, "the filter name is invalid")?; + if RESERVED.contains(&name.as_str()) { + return Err(RelayClientError::invalid_request( + "a filter name collides with a reserved Relay query parameter", + )); + } + validate_query_value(&value)?; + if self.filters.insert(name, value).is_some() { + return Err(RelayClientError::invalid_request( + "a filter name is duplicated", + )); + } + Ok(self) + } + + #[must_use] + pub fn bbox(mut self, value: BoundingBox) -> Self { + self.bbox = Some(value); + self + } + + pub(crate) fn pairs(&self) -> Result, RelayClientError> { + let mut pairs = Vec::new(); + if let Some(value) = self.page_size { + pairs.push(("pageSize".into(), value.to_string())); + } + self.options.append_query(&mut pairs); + if let Some(value) = self.bbox { + pairs.push(("bbox".into(), value.text())); + } + pairs.extend( + self.filters + .iter() + .map(|(name, value)| (name.clone(), value.clone())), + ); + ensure_query_bound(&pairs)?; + Ok(pairs) + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct LookupRequest { + pub(crate) options: RecordOptions, + pub(crate) selectors: BTreeMap, +} + +impl LookupRequest { + #[must_use] + pub fn options(mut self, options: RecordOptions) -> Self { + self.options = options; + self + } + + pub fn selector( + mut self, + name: impl Into, + value: Value, + ) -> Result { + let name = name.into(); + validate_identifier(&name, "the selector name is invalid")?; + if !matches!(value, Value::String(_) | Value::Bool(_) | Value::Number(_)) { + return Err(RelayClientError::invalid_request( + "a lookup selector must be a JSON string, boolean, or integer", + )); + } + if value.as_str().is_some_and(|text| { + text.is_empty() || text.len() > MAX_VALUE_BYTES || text.chars().any(char::is_control) + }) || value.as_number().is_some_and(|number| !number.is_i64()) + { + return Err(RelayClientError::invalid_request( + "a lookup selector value is invalid", + )); + } + if self.selectors.insert(name, value).is_some() { + return Err(RelayClientError::invalid_request( + "a lookup selector is duplicated", + )); + } + Ok(self) + } + + pub(crate) fn body(&self) -> Result, RelayClientError> { + if self.selectors.is_empty() { + return Err(RelayClientError::invalid_request( + "a lookup requires at least one selector", + )); + } + let body = serde_json::to_vec(&crate::model::LookupBody { + selectors: &self.selectors, + }) + .map_err(|_| { + RelayClientError::invalid_request("lookup selectors could not be serialized") + })?; + if body.len() > MAX_LOOKUP_BODY_BYTES { + return Err(RelayClientError::invalid_request( + "the lookup request exceeds the client body bound", + )); + } + Ok(body) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SdmxDataFormat { + #[default] + Json, + Csv, +} + +impl SdmxDataFormat { + pub(crate) const fn media_type(self) -> &'static str { + match self { + Self::Json => "application/vnd.sdmx.data+json;version=2.1.0", + Self::Csv => "application/vnd.sdmx.data+csv;version=2.1.0", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdmxDataRequest { + pub(crate) agency: String, + pub(crate) resource: String, + pub(crate) version: String, + pub(crate) key: Option, + pub(crate) constraints: BTreeMap, + pub(crate) offset: Option, + pub(crate) limit: Option, + pub(crate) dimension_at_observation: Option, + pub(crate) format: SdmxDataFormat, +} + +impl SdmxDataRequest { + pub fn new( + agency: impl Into, + resource: impl Into, + version: impl Into, + ) -> Result { + let request = Self { + agency: agency.into(), + resource: resource.into(), + version: version.into(), + key: None, + constraints: BTreeMap::new(), + offset: None, + limit: None, + dimension_at_observation: None, + format: SdmxDataFormat::Json, + }; + request.validate_path()?; + Ok(request) + } + + pub fn keyed(mut self, key: impl Into) -> Result { + let key = key.into(); + if !valid_sdmx_key(&key) { + return Err(RelayClientError::invalid_request("the SDMX key is invalid")); + } + self.key = Some(key); + Ok(self) + } + + pub fn constraint( + mut self, + component: impl Into, + expression: impl Into, + ) -> Result { + let component = component.into(); + let expression = expression.into(); + if !valid_sdmx_component_id(&component) { + return Err(RelayClientError::invalid_request( + "the SDMX component identifier is invalid", + )); + } + validate_query_value(&expression)?; + if self.constraints.insert(component, expression).is_some() { + return Err(RelayClientError::invalid_request( + "an SDMX component constraint is duplicated", + )); + } + Ok(self) + } + + #[must_use] + pub fn offset(mut self, value: u32) -> Self { + self.offset = Some(value); + self + } + + pub fn limit(mut self, value: u32) -> Result { + if value == 0 { + return Err(RelayClientError::invalid_request( + "the SDMX limit must be greater than zero", + )); + } + self.limit = Some(value); + Ok(self) + } + + pub fn dimension_at_observation( + mut self, + value: impl Into, + ) -> Result { + let value = value.into(); + if value != "AllDimensions" && !valid_sdmx_component_id(&value) { + return Err(RelayClientError::invalid_request( + "the SDMX dimension-at-observation identifier is invalid", + )); + } + self.dimension_at_observation = Some(value); + Ok(self) + } + + #[must_use] + pub fn format(mut self, value: SdmxDataFormat) -> Self { + self.format = value; + self + } + + fn validate_path(&self) -> Result<(), RelayClientError> { + if self.agency.len() > MAX_IDENTIFIER_BYTES + || !self.agency.split('.').all(valid_sdmx_ncname_segment) + || self.resource.len() > MAX_IDENTIFIER_BYTES + || !valid_sdmx_ncname_segment(&self.resource) + || !valid_sdmx_version(&self.version) + { + return Err(RelayClientError::invalid_request( + "an SDMX route identifier is invalid", + )); + } + Ok(()) + } + + pub(crate) fn pairs(&self) -> Result, RelayClientError> { + let mut pairs = self + .constraints + .iter() + .map(|(name, value)| (format!("c[{name}]"), value.clone())) + .collect::>(); + if let Some(value) = self.offset { + pairs.push(("offset".into(), value.to_string())); + } + if let Some(value) = self.limit { + pairs.push(("limit".into(), value.to_string())); + } + if let Some(value) = &self.dimension_at_observation { + pairs.push(("dimensionAtObservation".into(), value.clone())); + } + ensure_query_bound(&pairs)?; + Ok(pairs) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum SdmxStructureKind { + Dataflow, + DataStructure, +} + +impl SdmxStructureKind { + pub(crate) const fn path(self) -> &'static str { + match self { + Self::Dataflow => "dataflow", + Self::DataStructure => "datastructure", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdmxStructureRequest { + pub kind: SdmxStructureKind, + pub agency: String, + pub resource: String, + pub version: String, +} + +impl SdmxStructureRequest { + pub fn new( + kind: SdmxStructureKind, + agency: impl Into, + resource: impl Into, + version: impl Into, + ) -> Result { + let result = Self { + kind, + agency: agency.into(), + resource: resource.into(), + version: version.into(), + }; + if result.agency.len() > MAX_IDENTIFIER_BYTES + || !result.agency.split('.').all(valid_sdmx_ncname_segment) + || result.resource.len() > MAX_IDENTIFIER_BYTES + || !valid_sdmx_ncname_segment(&result.resource) + || !valid_sdmx_version(&result.version) + { + return Err(RelayClientError::invalid_request( + "an SDMX structure route identifier is invalid", + )); + } + Ok(result) + } +} + +fn validate_identifier(value: &str, reason: &'static str) -> Result<(), RelayClientError> { + if value.is_empty() + || value.len() > MAX_IDENTIFIER_BYTES + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~')) + { + return Err(RelayClientError::invalid_request(reason)); + } + Ok(()) +} + +fn valid_sdmx_ncname_segment(value: &str) -> bool { + let mut bytes = value.bytes(); + matches!(bytes.next(), Some(first) if first.is_ascii_alphabetic()) + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn valid_sdmx_component_id(value: &str) -> bool { + let mut bytes = value.bytes(); + value.len() <= MAX_IDENTIFIER_BYTES + && matches!(bytes.next(), Some(first) if first.is_ascii_uppercase()) + && bytes.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') +} + +fn valid_sdmx_key(value: &str) -> bool { + if value.is_empty() || value.len() > MAX_QUERY_BYTES || value.chars().any(char::is_control) { + return false; + } + let parts = value.split('.').collect::>(); + if parts.len() > 16 { + return false; + } + parts.into_iter().all(|part| { + if part == "*" { + return true; + } + if part.is_empty() || part.contains('+') { + return false; + } + let terms = part.split(',').collect::>(); + terms.len() <= 16 + && terms.into_iter().all(|term| { + let value = term.strip_prefix("eq:").unwrap_or(term); + !term.is_empty() + && !term.starts_with("ge:") + && !term.starts_with("le:") + && !value.is_empty() + && value.len() <= 1024 + }) + }) +} + +fn valid_sdmx_version(value: &str) -> bool { + let parts = value.split('.').collect::>(); + value.len() <= MAX_IDENTIFIER_BYTES + && parts.len() == 3 + && parts.iter().all(|part| { + !part.is_empty() + && part.bytes().all(|byte| byte.is_ascii_digit()) + && (*part == "0" || !part.starts_with('0')) + }) +} + +fn validate_fields(fields: &[String]) -> Result<(), RelayClientError> { + if fields.is_empty() { + return Err(RelayClientError::invalid_request( + "field selection must not be empty", + )); + } + let mut seen = std::collections::BTreeSet::new(); + for field in fields { + validate_identifier(field, "a selected field name is invalid")?; + if !seen.insert(field) { + return Err(RelayClientError::invalid_request( + "a selected field is duplicated", + )); + } + } + Ok(()) +} + +fn validate_query_value(value: &str) -> Result<(), RelayClientError> { + if value.is_empty() || value.len() > MAX_VALUE_BYTES || value.chars().any(char::is_control) { + return Err(RelayClientError::invalid_request( + "a query value is invalid", + )); + } + Ok(()) +} + +pub(crate) fn encoded_query(pairs: &[(String, String)]) -> String { + // The form serializer escapes a literal `+` as `%2B`. Relay therefore sees + // the SDMX range separator as plus, never as form-urlencoded space. + url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs( + pairs + .iter() + .map(|(name, value)| (name.as_str(), value.as_str())), + ) + .finish() +} + +fn ensure_query_bound(pairs: &[(String, String)]) -> Result<(), RelayClientError> { + if encoded_query(pairs).len() > MAX_QUERY_BYTES { + return Err(RelayClientError::invalid_request( + "the request query exceeds the client bound", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sdmx_literal_plus_is_never_decoded_as_space() { + let request = SdmxDataRequest::new("A", "F", "1.0.0") + .unwrap() + .constraint("TIME_PERIOD", "ge:2020+le:2024") + .unwrap(); + let text = encoded_query(&request.pairs().unwrap()); + assert!(text.contains("ge%3A2020%2Ble%3A2024")); + let decoded = url::form_urlencoded::parse(text.as_bytes()).collect::>(); + assert_eq!(decoded[0].1, "ge:2020+le:2024"); + } + + #[test] + fn sdmx_component_identifiers_match_the_compiled_uppercase_grammar() { + let base = SdmxDataRequest::new("A", "F", "1.0.0").unwrap(); + assert!(base.clone().constraint("TIME_PERIOD", "2024").is_ok()); + assert!(base.clone().constraint("time_period", "2024").is_err()); + assert!(base + .clone() + .dimension_at_observation("AllDimensions") + .is_ok()); + assert!(base.dimension_at_observation("time_period").is_err()); + } + + #[test] + fn keyed_sdmx_preserves_bounded_non_code_strings_but_rejects_query_grammar() { + let base = SdmxDataRequest::new("A", "F", "1.0.0").unwrap(); + assert!(base.clone().keyed("South East,กรุงเทพ.*").is_ok()); + for invalid in ["", ".value", "value.", "ge:2020", "one+two", "one,,two"] { + assert!(base.clone().keyed(invalid).is_err(), "accepted {invalid:?}"); + } + assert!(base + .keyed((0..17).map(|_| "*").collect::>().join(".")) + .is_err()); + } + + #[test] + fn first_page_filters_cannot_claim_reserved_parameters() { + let error = CollectionRequest::default() + .filter("cursor", "opaque") + .unwrap_err(); + assert!(matches!(error, RelayClientError::InvalidRequest { .. })); + } + + #[test] + fn request_builders_reject_ambiguous_or_oversized_first_page_facts() { + assert!(ResourceListRequest::default().page_size(101).is_err()); + assert!(BoundingBox::new(10.0, -1.0, -10.0, 1.0).is_err()); + assert!(RecordOptions::default().fields(["name", "name"]).is_err()); + assert!(LookupRequest::default() + .selector("subject", serde_json::json!({"nested": true})) + .is_err()); + } + + #[test] + fn lookup_body_has_exactly_one_outer_selectors_member() { + let request = LookupRequest::default() + .selector("number", serde_json::json!(42)) + .unwrap() + .selector("active", serde_json::json!(true)) + .unwrap(); + let value: serde_json::Value = serde_json::from_slice(&request.body().unwrap()).unwrap(); + assert_eq!(value.as_object().map(serde_json::Map::len), Some(1)); + assert!(value.get("selectors").is_some()); + } +} diff --git a/crates/registry-relay-client/src/response.rs b/crates/registry-relay-client/src/response.rs new file mode 100644 index 000000000..653c22ce6 --- /dev/null +++ b/crates/registry-relay-client/src/response.rs @@ -0,0 +1,339 @@ +use std::fmt; + +use registry_platform_httpsec::TraceId; +use serde::{Deserialize, Serialize}; + +use crate::RecordFormat; + +/// A validated strong entity tag over SHA-256 bytes (`"` plus 64 lower hex digits plus `"`). +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct StrongEtag(String); + +impl StrongEtag { + pub fn parse(value: &str) -> Result { + let bytes = value.as_bytes(); + if bytes.len() != 66 + || bytes.first() != Some(&b'"') + || bytes.last() != Some(&b'"') + || !bytes[1..65] + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(StrongEtagError); + } + Ok(Self(value.to_owned())) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] +#[error("entity tag is not a strong quoted SHA-256 tag")] +pub struct StrongEtagError; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResponseMetadata { + trace_id: TraceId, + #[serde(skip_serializing_if = "Option::is_none")] + etag: Option, +} + +impl ResponseMetadata { + pub(crate) fn new(trace_id: TraceId, etag: Option) -> Self { + Self { trace_id, etag } + } + #[must_use] + pub fn trace_id(&self) -> &TraceId { + &self.trace_id + } + #[must_use] + pub fn etag(&self) -> Option<&StrongEtag> { + self.etag.as_ref() + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Complete { + pub value: T, + pub metadata: ResponseMetadata, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotModified { + pub etag: StrongEtag, + pub trace_id: TraceId, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Conditional { + Complete(Complete), + NotModified(NotModified), +} + +/// An artifact or protocol document whose shape belongs outside the SDK kernel. +#[derive(Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RawDocument { + media_type: String, + bytes: Vec, +} + +impl RawDocument { + pub(crate) fn new(media_type: String, bytes: Vec) -> Self { + Self { media_type, bytes } + } + #[must_use] + pub fn media_type(&self) -> &str { + &self.media_type + } + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } +} + +impl fmt::Debug for RawDocument { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RawDocument") + .field("media_type", &self.media_type) + .field("body_bytes", &self.bytes.len()) + .finish_non_exhaustive() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum CollectionRoute { + Records { resource: String }, + Search { resource: String, search: String }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, tag = "kind", rename_all = "camelCase")] +pub enum CollectionRouteProjection { + Records { resource: String }, + Search { resource: String, search: String }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct CollectionContinuationProjection { + pub route: CollectionRouteProjection, + pub cursor: String, + pub format: RecordFormat, + #[serde(skip_serializing_if = "Option::is_none")] + pub access_profile: Option, +} + +/// Opaque server cursor plus the caller-selected route representation. +/// +/// Only [`crate::RelayClient::continue_collection`] consumes this type. Its +/// internals are deliberately private, preventing callers from mixing a cursor +/// with first-page filters, fields, bbox, page size, or a different route. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CollectionContinuation { + pub(crate) cursor: String, + pub(crate) route: CollectionRoute, + pub(crate) format: RecordFormat, + pub(crate) access_profile: Option, +} + +impl CollectionContinuation { + #[must_use] + pub fn projection(&self) -> CollectionContinuationProjection { + CollectionContinuationProjection { + route: match &self.route { + CollectionRoute::Records { resource } => CollectionRouteProjection::Records { + resource: resource.clone(), + }, + CollectionRoute::Search { resource, search } => CollectionRouteProjection::Search { + resource: resource.clone(), + search: search.clone(), + }, + }, + cursor: self.cursor.clone(), + format: self.format, + access_profile: self.access_profile.clone(), + } + } + + pub fn try_from_projection( + value: CollectionContinuationProjection, + ) -> Result { + validate_cursor(&value.cursor)?; + let route = match value.route { + CollectionRouteProjection::Records { resource } => { + validate_route_identifier(&resource)?; + CollectionRoute::Records { resource } + } + CollectionRouteProjection::Search { resource, search } => { + validate_route_identifier(&resource)?; + validate_route_identifier(&search)?; + CollectionRoute::Search { resource, search } + } + }; + if let Some(profile) = &value.access_profile { + validate_route_identifier(profile)?; + } + Ok(Self { + cursor: value.cursor, + route, + format: value.format, + access_profile: value.access_profile, + }) + } +} + +impl Serialize for CollectionContinuation { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.projection().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for CollectionContinuation { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::try_from_projection(CollectionContinuationProjection::deserialize(deserializer)?) + .map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CollectionPage { + pub value: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub continuation: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceContinuationProjection { + pub cursor: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResourceContinuation { + pub(crate) cursor: String, +} + +impl ResourceContinuation { + #[must_use] + pub fn cursor(&self) -> &str { + &self.cursor + } + + pub fn try_from_cursor(cursor: impl Into) -> Result { + let cursor = cursor.into(); + validate_cursor(&cursor)?; + Ok(Self { cursor }) + } + + #[must_use] + pub fn projection(&self) -> ResourceContinuationProjection { + ResourceContinuationProjection { + cursor: self.cursor.clone(), + } + } + + pub fn try_from_projection( + value: ResourceContinuationProjection, + ) -> Result { + Self::try_from_cursor(value.cursor) + } +} + +impl Serialize for ResourceContinuation { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.projection().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ResourceContinuation { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::try_from_projection(ResourceContinuationProjection::deserialize(deserializer)?) + .map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourcePage { + pub value: T, + #[serde(skip_serializing_if = "Option::is_none")] + pub continuation: Option, +} + +pub(crate) fn validate_cursor(value: &str) -> Result<(), crate::RelayClientError> { + if value.is_empty() + || value.len() > 16 * 1024 + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(crate::RelayClientError::invalid_request( + "the continuation cursor is invalid", + )); + } + Ok(()) +} + +fn validate_route_identifier(value: &str) -> Result<(), crate::RelayClientError> { + if value.is_empty() + || value.len() > 128 + || value.starts_with('-') + || value.ends_with('-') + || value.contains("--") + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(crate::RelayClientError::invalid_request( + "a continuation route identifier is invalid", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::CollectionContinuationProjection; + use serde_json::json; + + #[test] + fn collection_continuation_projection_rejects_unknown_outer_and_route_members() { + let valid = json!({ + "route": {"kind": "records", "resource": "people"}, + "cursor": "opaque-cursor", + "format": "json" + }); + assert!(serde_json::from_value::(valid.clone()).is_ok()); + + let mut outer = valid.clone(); + outer["unexpected"] = json!(true); + assert!(serde_json::from_value::(outer).is_err()); + + let mut route = valid; + route["route"]["unexpected"] = json!(true); + assert!(serde_json::from_value::(route).is_err()); + } +} diff --git a/crates/registry-relay-client/src/transport.rs b/crates/registry-relay-client/src/transport.rs new file mode 100644 index 000000000..089f272ba --- /dev/null +++ b/crates/registry-relay-client/src/transport.rs @@ -0,0 +1,194 @@ +use registry_platform_httpsec::{response_trace_id, ProblemDefinition, ProblemDocument, TraceId}; +use registry_platform_httputil::client::{ + build_client, read_failure_kind, send_failure_kind, OutboundOptions, ServiceBaseUrl, +}; +use registry_platform_httputil::{ + read_bounded, retry_after_seconds, url::append_path_segments, validate_response_headers, +}; +use registry_relay_http_contract::{ProblemCode, PROBLEM_MEDIA_TYPE}; +use reqwest::header::{HeaderMap, CONTENT_TYPE, ETAG}; +use reqwest::{Response, StatusCode, Url}; + +use crate::{ProtocolFailure, RelayClientConfig, RelayClientError, StrongEtag}; + +const MAXIMUM_PROBLEM_BYTES: usize = 4 * 1024; +const MAXIMUM_RETRY_AFTER_SECONDS: u64 = 60; + +pub(crate) struct Transport { + pub(crate) http: reqwest::Client, + pub(crate) base_url: ServiceBaseUrl, + pub(crate) max_response_bytes: u64, +} + +impl Transport { + pub(crate) fn new(config: &RelayClientConfig) -> Result { + let base_url = ServiceBaseUrl::new(config.base_url.clone()) + .map_err(|_| RelayClientError::configuration("the service base URL is not usable"))?; + let http = build_client(OutboundOptions { + request_timeout: config.request_timeout, + connect_timeout: config.connect_timeout, + user_agent: config.user_agent.as_deref(), + trusted_root_certificates: config + .trusted_root_certificates + .as_deref() + .map(Vec::as_slice), + }) + .map_err(RelayClientError::configuration)?; + Ok(Self { + http, + base_url, + max_response_bytes: config.max_response_bytes, + }) + } + + pub(crate) fn url(&self, segments: &[&str]) -> Result { + append_path_segments(self.base_url.as_url(), segments) + .map_err(|_| RelayClientError::configuration("a route identifier cannot be encoded")) + } + + pub(crate) async fn send( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + let response = request + .send() + .await + .map_err(|error| RelayClientError::transport(send_failure_kind(&error)))?; + validate_response_headers(response.headers()).map_err(|_| { + RelayClientError::protocol( + response.status().as_u16(), + ProtocolFailure::HeaderBounds, + None, + ) + })?; + Ok(response) + } + + pub(crate) async fn read( + &self, + response: Response, + maximum: u64, + ) -> Result, RelayClientError> { + read_bounded(response, maximum.min(self.max_response_bytes)) + .await + .map_err(|error| RelayClientError::transport(read_failure_kind(&error))) + } +} + +pub(crate) fn trace_id( + status: StatusCode, + headers: &HeaderMap, +) -> Result { + response_trace_id(headers).map_err(|_| { + RelayClientError::protocol(status.as_u16(), ProtocolFailure::TraceContext, None) + }) +} + +pub(crate) fn exact_media_type(headers: &HeaderMap, expected: &str) -> bool { + let mut values = headers.get_all(CONTENT_TYPE).iter(); + matches!((values.next(), values.next()), (Some(value), None) if value.as_bytes() == expected.as_bytes()) +} + +pub(crate) fn response_etag( + status: StatusCode, + headers: &HeaderMap, +) -> Result, RelayClientError> { + let mut values = headers.get_all(ETAG).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(RelayClientError::protocol( + status.as_u16(), + ProtocolFailure::EntityTag, + None, + )); + } + let value = value.to_str().map_err(|_| { + RelayClientError::protocol(status.as_u16(), ProtocolFailure::EntityTag, None) + })?; + StrongEtag::parse(value) + .map(Some) + .map_err(|_| RelayClientError::protocol(status.as_u16(), ProtocolFailure::EntityTag, None)) +} + +pub(crate) async fn problem(response: Response, transport: &Transport) -> RelayClientError { + let status = response.status(); + let headers = response.headers().clone(); + let trace = match trace_id(status, &headers) { + Ok(trace) => trace, + Err(error) => return error, + }; + if !exact_media_type(&headers, PROBLEM_MEDIA_TYPE) { + return RelayClientError::protocol( + status.as_u16(), + ProtocolFailure::MediaType, + Some(trace), + ); + } + let retry = (status == StatusCode::TOO_MANY_REQUESTS) + .then(|| retry_after_seconds(&headers, MAXIMUM_RETRY_AFTER_SECONDS)) + .flatten(); + let body = match transport.read(response, MAXIMUM_PROBLEM_BYTES as u64).await { + Ok(value) => value, + Err(error) => return error, + }; + let document = match ProblemDocument::parse_exact(&body, MAXIMUM_PROBLEM_BYTES) { + Ok(value) => value, + Err(_) => { + return RelayClientError::protocol( + status.as_u16(), + ProtocolFailure::Problem, + Some(trace), + ) + } + }; + let definitions = ProblemCode::ALL + .iter() + .map(|code| ProblemDefinition { + type_uri: code.type_uri(), + title: code.title(), + status: code.status(), + detail: code.detail(), + code: code.code(), + }) + .collect::>(); + let Some(index) = document.definition_index(&definitions) else { + return RelayClientError::protocol(status.as_u16(), ProtocolFailure::Problem, Some(trace)); + }; + let code = ProblemCode::ALL[index]; + if code.status() != status.as_u16() || trace != document.trace_id { + return RelayClientError::protocol(status.as_u16(), ProtocolFailure::Problem, Some(trace)); + } + RelayClientError::Problem { + status: status.as_u16(), + code, + trace_id: trace, + retry_after_seconds: retry, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::header::HeaderValue; + + #[test] + fn entity_tags_are_exact_strong_sha256_tags() { + let mut headers = HeaderMap::new(); + headers.insert( + ETAG, + HeaderValue::from_static( + "\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"", + ), + ); + assert!(response_etag(StatusCode::OK, &headers).unwrap().is_some()); + for invalid in [ + "W/\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"", + "\"ABCDEF\"", + ] { + headers.insert(ETAG, HeaderValue::from_str(invalid).unwrap()); + assert!(response_etag(StatusCode::OK, &headers).is_err()); + } + } +} diff --git a/crates/registry-relay-client/tests/contract_parity.rs b/crates/registry-relay-client/tests/contract_parity.rs new file mode 100644 index 000000000..1ce280fe8 --- /dev/null +++ b/crates/registry-relay-client/tests/contract_parity.rs @@ -0,0 +1,33 @@ +use registry_relay_client::ProblemCode; +use registry_relay_http_contract::{routes, PROBLEM_MEDIA_TYPE}; + +#[test] +fn fixed_route_inventory_and_problem_catalog_are_shared() { + assert_eq!( + routes::ALL, + [ + "/health", + "/ready", + "/openapi.json", + "/v2", + "/v2/resources", + "/v2/resources/{resource}", + "/v2/resources/{resource}/records", + "/v2/resources/{resource}/records/{record_identifier}", + "/v2/resources/{resource}/lookups/{lookup}", + "/v2/resources/{resource}/searches/{search}", + "/v2/artifacts/{artifact_identifier}", + "/sdmx/v2/data/{context}/{agency}/{resource}/{version}/{key}", + "/sdmx/v2/data/{context}/{agency}/{resource}/{version}", + "/sdmx/v2/structure/{artefact_type}/{agency}/{resource}/{version}", + ] + ); + assert_eq!(PROBLEM_MEDIA_TYPE, "application/problem+json"); + assert_eq!(ProblemCode::ALL.len(), 26); + for problem in ProblemCode::ALL { + assert!((400..=599).contains(&problem.status())); + assert!(problem + .type_uri() + .contains(&problem.code().replace('.', "/"))); + } +} diff --git a/crates/registry-relay-client/tests/http_boundary.rs b/crates/registry-relay-client/tests/http_boundary.rs new file mode 100644 index 000000000..e4c732157 --- /dev/null +++ b/crates/registry-relay-client/tests/http_boundary.rs @@ -0,0 +1,651 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use axum::body::Body; +use axum::extract::State; +use axum::http::{Request, Response, StatusCode}; +use axum::routing::any; +use axum::Router; +use registry_platform_httputil::client::{BearerToken, TokenError, TokenProvider}; +use registry_relay_client::{ + CollectionContinuation, CollectionContinuationProjection, CollectionRequest, + CollectionRouteProjection, Conditional, LookupRequest, ProblemCode, RecordFormat, + RecordOptions, RelayClient, RelayClientConfig, RelayClientError, ResourceContinuation, + ResourceListRequest, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, StrongEtag, +}; +use serde_json::json; +use tokio::net::TcpListener; +use url::Url; + +const TRACE_ID: &str = "4bf92f3577b34da6a3ce929d0e0e4736"; +const TRACEPARENT: &str = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; +const ETAG: &str = "\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\""; + +#[derive(Clone)] +struct TestState { + paths: Arc>>, + mode: Mode, +} + +#[derive(Clone, Copy)] +enum Mode { + Routes, + TraceMissing, + TraceDuplicate, + TraceUppercase, + ProblemTraceMismatch, + ProblemExtraMember, + ProblemTraceMissing, + ProblemTraceDuplicate, + ProblemTraceUppercase, + NotModified, + NotModifiedMissingEtag, + NotModifiedWrongEtag, + Redirect, + RateLimited, + RateLimitedOverBound, + ServiceRetry, + Artifact, + ArtifactInvalidMedia, + ArtifactDuplicateMedia, + TooManyHeaders, + WrongMedia, +} + +async fn handler(State(state): State, request: Request) -> Response { + state + .paths + .lock() + .expect("path capture lock") + .push(request.uri().to_string()); + match state.mode { + Mode::TraceMissing => wire_response( + StatusCode::OK, + "application/json", + br#"{"status":"ok"}"#, + None, + ), + Mode::TraceDuplicate => { + let mut response = wire_response( + StatusCode::OK, + "application/json", + br#"{"status":"ok"}"#, + Some(TRACEPARENT), + ); + response + .headers_mut() + .append("traceparent", TRACEPARENT.parse().expect("traceparent")); + response + } + Mode::TraceUppercase => wire_response( + StatusCode::OK, + "application/json", + br#"{"status":"ok"}"#, + Some("00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01"), + ), + Mode::ProblemTraceMismatch => { + let code = ProblemCode::ResourceNotFound; + let body = serde_json::to_vec(&json!({ + "type": code.type_uri(), "title": code.title(), "status": code.status(), + "detail": code.detail(), "code": code.code(), + "traceId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + })) + .expect("problem serializes"); + wire_response( + StatusCode::NOT_FOUND, + "application/problem+json", + &body, + Some(TRACEPARENT), + ) + } + Mode::ProblemExtraMember => { + let code = ProblemCode::ResourceNotFound; + let body = serde_json::to_vec(&json!({ + "type": code.type_uri(), "title": code.title(), "status": code.status(), + "detail": code.detail(), "code": code.code(), "traceId": TRACE_ID, + "unregistered": "must be refused", + })) + .expect("problem serializes"); + wire_response( + StatusCode::NOT_FOUND, + "application/problem+json", + &body, + Some(TRACEPARENT), + ) + } + Mode::ProblemTraceMissing => { + let mut response = registered_problem(ProblemCode::ResourceNotFound); + response.headers_mut().remove("traceparent"); + response + } + Mode::ProblemTraceDuplicate => { + let mut response = registered_problem(ProblemCode::ResourceNotFound); + response + .headers_mut() + .append("traceparent", TRACEPARENT.parse().expect("traceparent")); + response + } + Mode::ProblemTraceUppercase => { + let mut response = registered_problem(ProblemCode::ResourceNotFound); + response.headers_mut().insert( + "traceparent", + "00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01" + .parse() + .expect("traceparent"), + ); + response + } + Mode::NotModified => { + let mut response = wire_response( + StatusCode::NOT_MODIFIED, + "application/json", + b"", + Some(TRACEPARENT), + ); + response + .headers_mut() + .insert("etag", ETAG.parse().expect("etag")); + response + } + Mode::NotModifiedMissingEtag => wire_response( + StatusCode::NOT_MODIFIED, + "application/json", + b"", + Some(TRACEPARENT), + ), + Mode::NotModifiedWrongEtag => { + let mut response = wire_response( + StatusCode::NOT_MODIFIED, + "application/json", + b"", + Some(TRACEPARENT), + ); + response.headers_mut().insert( + "etag", + "\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"" + .parse() + .expect("etag"), + ); + response + } + Mode::Redirect => { + let mut response = wire_response( + StatusCode::FOUND, + "text/plain", + b"redirect", + Some(TRACEPARENT), + ); + response.headers_mut().insert( + "location", + "/tenant/prefix/ready".parse().expect("location"), + ); + response + } + Mode::RateLimited => problem_with_retry(ProblemCode::RateLimited), + Mode::RateLimitedOverBound => { + let mut response = registered_problem(ProblemCode::RateLimited); + response + .headers_mut() + .insert("retry-after", "61".parse().expect("retry-after")); + response + } + Mode::ServiceRetry => problem_with_retry(ProblemCode::ServiceNotReady), + Mode::Artifact => wire_response( + StatusCode::OK, + "application/yaml; charset=utf-8", + b"openapi: 3.1.0\n", + Some(TRACEPARENT), + ), + Mode::ArtifactInvalidMedia => wire_response( + StatusCode::OK, + "application/yaml; broken", + b"untrusted", + Some(TRACEPARENT), + ), + Mode::ArtifactDuplicateMedia => { + let mut response = wire_response( + StatusCode::OK, + "application/yaml", + b"untrusted", + Some(TRACEPARENT), + ); + response.headers_mut().append( + "content-type", + "application/json".parse().expect("content type"), + ); + response + } + Mode::TooManyHeaders => { + let mut response = wire_response( + StatusCode::OK, + "application/json", + br#"{"status":"ok"}"#, + Some(TRACEPARENT), + ); + for index in 0..65 { + response.headers_mut().insert( + format!("x-bound-{index}") + .parse::() + .expect("header name"), + "value".parse().expect("header value"), + ); + } + response + } + Mode::WrongMedia => wire_response( + StatusCode::OK, + "text/plain", + br#"{"status":"ok"}"#, + Some(TRACEPARENT), + ), + Mode::Routes => route_response(request.uri().path()), + } +} + +fn route_response(path: &str) -> Response { + if path.ends_with("/health") { + return wire_response( + StatusCode::OK, + "application/json", + br#"{"status":"ok"}"#, + Some(TRACEPARENT), + ); + } + if path.ends_with("/ready") { + return wire_response( + StatusCode::OK, + "application/json", + br#"{"status":"ready"}"#, + Some(TRACEPARENT), + ); + } + if path.ends_with("/openapi.json") { + return wire_response( + StatusCode::OK, + "application/json", + br#"{"openapi":"3.1.0"}"#, + Some(TRACEPARENT), + ); + } + registered_problem(ProblemCode::ResourceNotFound) +} + +fn registered_problem(code: ProblemCode) -> Response { + let body = serde_json::to_vec(&json!({ + "type": code.type_uri(), "title": code.title(), "status": code.status(), + "detail": code.detail(), "code": code.code(), "traceId": TRACE_ID, + })) + .expect("problem serializes"); + wire_response( + StatusCode::from_u16(code.status()).expect("problem status"), + "application/problem+json", + &body, + Some(TRACEPARENT), + ) +} + +fn problem_with_retry(code: ProblemCode) -> Response { + let mut response = registered_problem(code); + response + .headers_mut() + .insert("retry-after", "60".parse().expect("retry-after")); + response +} + +fn wire_response( + status: StatusCode, + media: &str, + body: &[u8], + traceparent: Option<&str>, +) -> Response { + let mut response = Response::new(Body::from(body.to_vec())); + *response.status_mut() = status; + response + .headers_mut() + .insert("content-type", media.parse().expect("response media type")); + if let Some(traceparent) = traceparent { + response + .headers_mut() + .insert("traceparent", traceparent.parse().expect("traceparent")); + } + response +} + +async fn test_client( + mode: Mode, + provider: Option>, +) -> (RelayClient, Arc>>) { + test_client_with_max(mode, provider, 8 * 1024 * 1024).await +} + +async fn test_client_with_max( + mode: Mode, + provider: Option>, + max_response_bytes: u64, +) -> (RelayClient, Arc>>) { + let paths = Arc::new(Mutex::new(Vec::new())); + let state = TestState { + paths: paths.clone(), + mode, + }; + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let address = listener.local_addr().expect("test address"); + tokio::spawn(async move { + axum::serve( + listener, + Router::new().fallback(any(handler)).with_state(state), + ) + .await + .expect("test server"); + }); + let mut config = RelayClientConfig::new( + Url::parse(&format!("http://{address}/tenant/prefix")).expect("base URL"), + ) + .with_max_response_bytes(max_response_bytes); + if let Some(provider) = provider { + config = config.with_token_provider(provider); + } + (RelayClient::new(config).expect("client"), paths) +} + +#[derive(Debug)] +struct CountingToken(AtomicUsize); + +#[async_trait] +impl TokenProvider for CountingToken { + async fn bearer_token(&self) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + BearerToken::new("secret-token") + } +} + +#[tokio::test] +async fn probes_and_openapi_never_acquire_a_token() { + let provider = Arc::new(CountingToken(AtomicUsize::new(0))); + let (client, _) = test_client(Mode::Routes, Some(provider.clone())).await; + client.health().await.expect("health"); + client.ready().await.expect("ready"); + client.openapi(None).await.expect("openapi"); + assert_eq!(provider.0.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn base_prefix_is_preserved_for_every_route_family() { + let (client, paths) = test_client(Mode::Routes, None).await; + let _ = client.health().await; + let _ = client.ready().await; + let _ = client.openapi(None).await; + let _ = client.service_metadata(None).await; + let _ = client.resources(ResourceListRequest::default(), None).await; + let _ = client.resource("people", None).await; + let _ = client + .list_records("people", &CollectionRequest::default(), None) + .await; + let _ = client + .search_records("people", "by-name", &CollectionRequest::default(), None) + .await; + let _ = client + .read_record("people", "person-1", &RecordOptions::default(), None) + .await; + let lookup = LookupRequest::default() + .selector("number", json!(42)) + .expect("lookup request"); + let _ = client + .lookup_record("people", "by-number", &lookup, None) + .await; + let _ = client.artifact("schema", None).await; + let data = SdmxDataRequest::new("AGENCY", "FLOW", "1.0.0") + .expect("data request") + .constraint("TIME_PERIOD", "ge:2020+le:2024") + .expect("time constraint"); + let _ = client.sdmx_data(&data, None).await; + let keyed = SdmxDataRequest::new("AGENCY", "FLOW", "1.0.0") + .expect("data request") + .keyed("South East,กรุงเทพ.*") + .expect("non-code key"); + let _ = client.sdmx_data(&keyed, None).await; + let structure = + SdmxStructureRequest::new(SdmxStructureKind::Dataflow, "AGENCY", "FLOW", "1.0.0") + .expect("structure request"); + let _ = client.sdmx_structure(&structure, None).await; + let paths = paths.lock().expect("captured paths").clone(); + assert!( + paths.iter().all(|path| path.starts_with("/tenant/prefix/")), + "{paths:?}" + ); + for expected in [ + "/tenant/prefix/health", + "/tenant/prefix/ready", + "/tenant/prefix/openapi.json", + "/tenant/prefix/v2", + "/tenant/prefix/v2/resources", + "/tenant/prefix/v2/resources/people", + "/tenant/prefix/v2/resources/people/records", + "/tenant/prefix/v2/resources/people/searches/by-name", + "/tenant/prefix/v2/resources/people/records/person-1", + "/tenant/prefix/v2/resources/people/lookups/by-number", + "/tenant/prefix/v2/artifacts/schema", + ] { + assert!( + paths + .iter() + .any(|path| path.split('?').next() == Some(expected)), + "missing {expected} in {paths:?}" + ); + } + assert!(paths + .iter() + .any(|path| path.starts_with("/tenant/prefix/sdmx/v2/data/dataflow/AGENCY/FLOW/1.0.0"))); + assert!(paths + .iter() + .any(|path| path.contains("ge%3A2020%2Ble%3A2024"))); + assert!(paths + .iter() + .any(|path| path.contains("South%20East,") && path.contains("%E0%B8%81"))); +} + +#[tokio::test] +async fn continuation_preserves_access_profile_and_representation_without_first_page_facts() { + let (client, paths) = test_client(Mode::Routes, None).await; + let continuation = + CollectionContinuation::try_from_projection(CollectionContinuationProjection { + route: CollectionRouteProjection::Records { + resource: "people".into(), + }, + cursor: "opaque_cursor-123".into(), + format: RecordFormat::JsonFg, + access_profile: Some("public".into()), + }) + .expect("continuation"); + let _ = client.continue_collection(&continuation, None).await; + let paths = paths.lock().expect("captured paths"); + let path = paths.last().expect("continuation request"); + assert!(path.contains("cursor=opaque_cursor-123"), "{path}"); + assert!(path.contains("accessProfile=public"), "{path}"); + assert!(!path.contains("formatProfile="), "{path}"); + assert!(!path.contains("fields="), "{path}"); + assert!(!path.contains("pageSize="), "{path}"); +} + +#[tokio::test] +async fn response_trace_requires_one_canonical_lowercase_v0_traceparent() { + for mode in [ + Mode::TraceMissing, + Mode::TraceDuplicate, + Mode::TraceUppercase, + ] { + let (client, _) = test_client(mode, None).await; + let error = client.health().await.expect_err("invalid trace refused"); + assert!(matches!(error, RelayClientError::Protocol { .. })); + } + let (client, _) = test_client(Mode::Routes, None).await; + assert_eq!( + client + .health() + .await + .expect("canonical trace") + .metadata + .trace_id() + .as_str(), + TRACE_ID + ); +} + +#[tokio::test] +async fn problem_requires_exact_registered_six_member_body_and_trace_equality() { + for mode in [ + Mode::ProblemTraceMismatch, + Mode::ProblemExtraMember, + Mode::ProblemTraceMissing, + Mode::ProblemTraceDuplicate, + Mode::ProblemTraceUppercase, + ] { + let (client, _) = test_client(mode, None).await; + let error = client + .resource("people", None) + .await + .expect_err("malformed problem refused"); + assert!(matches!(error, RelayClientError::Protocol { .. })); + } + let (client, _) = test_client(Mode::Routes, None).await; + let error = client + .resource("people", None) + .await + .expect_err("registered problem"); + assert_eq!(error.problem_code(), Some(ProblemCode::ResourceNotFound)); +} + +#[tokio::test] +async fn not_modified_http_requires_matching_strong_sha256_etag() { + let (client, _) = test_client(Mode::NotModified, None).await; + let etag = StrongEtag::parse(ETAG).expect("strong etag"); + let result = client.openapi(Some(&etag)).await.expect("valid 304"); + assert!(matches!(result, Conditional::NotModified(_))); + for mode in [Mode::NotModifiedWrongEtag, Mode::NotModifiedMissingEtag] { + let (client, _) = test_client(mode, None).await; + let error = client + .openapi(Some(&etag)) + .await + .expect_err("invalid not-modified response refused"); + assert!(matches!(error, RelayClientError::Protocol { .. })); + } +} + +#[tokio::test] +async fn redirects_are_reported_without_following_the_location() { + let (client, paths) = test_client(Mode::Redirect, None).await; + let error = client.health().await.expect_err("redirect refused"); + assert!(matches!(error, RelayClientError::Protocol { .. })); + assert_eq!(paths.lock().expect("paths").len(), 1); +} + +#[tokio::test] +async fn only_registered_429_exposes_bounded_retry_after() { + let (client, paths) = test_client(Mode::RateLimited, None).await; + let error = client + .resource("people", None) + .await + .expect_err("rate limit problem"); + assert_eq!(error.problem_code(), Some(ProblemCode::RateLimited)); + assert_eq!(error.retry_after_seconds(), Some(60)); + assert_eq!(paths.lock().expect("paths").len(), 1); + + let (client, _) = test_client(Mode::RateLimitedOverBound, None).await; + let error = client + .resource("people", None) + .await + .expect_err("over-bound retry guidance"); + assert_eq!(error.problem_code(), Some(ProblemCode::RateLimited)); + assert_eq!(error.retry_after_seconds(), None); + + let (client, _) = test_client(Mode::ServiceRetry, None).await; + let error = client + .resource("people", None) + .await + .expect_err("service problem"); + assert_eq!(error.problem_code(), Some(ProblemCode::ServiceNotReady)); + assert_eq!(error.retry_after_seconds(), None); +} + +#[tokio::test] +async fn artifact_preserves_one_syntactically_valid_server_media_type() { + let (client, _) = test_client(Mode::Artifact, None).await; + let response = client + .artifact("openapi-full", None) + .await + .expect("artifact"); + let Conditional::Complete(complete) = response else { + panic!("artifact unexpectedly not modified"); + }; + assert_eq!( + complete.value.media_type(), + "application/yaml; charset=utf-8" + ); + assert_eq!(complete.value.as_bytes(), b"openapi: 3.1.0\n"); + assert!(!format!("{:?}", complete.value).contains("openapi: 3.1.0")); + + for mode in [Mode::ArtifactInvalidMedia, Mode::ArtifactDuplicateMedia] { + let (client, _) = test_client(mode, None).await; + assert!(matches!( + client + .artifact("openapi-full", None) + .await + .expect_err("invalid artifact media refused"), + RelayClientError::Protocol { .. } + )); + } +} + +#[tokio::test] +async fn response_headers_media_and_bodies_are_bounded_before_exposure() { + let (client, _) = test_client(Mode::TooManyHeaders, None).await; + let error = client.health().await.expect_err("header count refused"); + assert_eq!(error.status(), Some(200)); + + let (client, _) = test_client(Mode::WrongMedia, None).await; + assert!(matches!( + client.health().await.expect_err("media type refused"), + RelayClientError::Protocol { .. } + )); + + let (client, _) = test_client_with_max(Mode::Routes, None, 4).await; + assert!(matches!( + client.health().await.expect_err("body bound refused"), + RelayClientError::Transport { .. } + )); +} + +#[test] +fn continuations_cannot_mix_first_page_facts() { + assert!(ResourceContinuation::try_from_cursor("bad+cursor").is_err()); + let resource = ResourceContinuation::try_from_cursor("opaque_resource-123") + .expect("resource continuation"); + let projection = resource.projection(); + assert_eq!(projection.cursor, "opaque_resource-123"); + assert_eq!( + ResourceContinuation::try_from_projection(projection) + .expect("rehydrated resource continuation") + .cursor(), + "opaque_resource-123" + ); + let projection = CollectionContinuationProjection { + route: CollectionRouteProjection::Search { + resource: "people".into(), + search: "by-name".into(), + }, + cursor: "opaque_cursor-123".into(), + format: RecordFormat::JsonFg, + access_profile: Some("caseworker".into()), + }; + let continuation = + CollectionContinuation::try_from_projection(projection.clone()).expect("continuation"); + assert_eq!(continuation.projection(), projection); + let serialized = serde_json::to_value(&continuation).expect("serializes"); + assert!(serialized.get("pageSize").is_none()); + assert!(serialized.get("fields").is_none()); + assert!(serialized.get("filters").is_none()); + assert!(serialized.get("bbox").is_none()); +} diff --git a/crates/registry-relay-http-contract/Cargo.toml b/crates/registry-relay-http-contract/Cargo.toml new file mode 100644 index 000000000..dceb225f2 --- /dev/null +++ b/crates/registry-relay-http-contract/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "registry-relay-http-contract" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Stable HTTP wire contract for Registry Relay V2 clients." +repository.workspace = true +publish = false + +[lints] +workspace = true diff --git a/crates/registry-relay-http-contract/src/lib.rs b/crates/registry-relay-http-contract/src/lib.rs new file mode 100644 index 000000000..cadaaedbe --- /dev/null +++ b/crates/registry-relay-http-contract/src/lib.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Stable HTTP wire-contract identifiers shared by Registry Relay V2 and its clients. +//! +//! This crate intentionally has no runtime or HTTP-framework dependency. Response +//! serialization, headers, and trace context remain owned by the Relay runtime. + +/// RFC 9457 media type emitted for Relay problem responses. +pub const PROBLEM_MEDIA_TYPE: &str = "application/problem+json"; + +/// Fixed Relay V2 HTTP route templates. +pub mod routes { + /// Process liveness probe. + pub const HEALTH: &str = "/health"; + /// Compiled Registry readiness probe. + pub const READY: &str = "/ready"; + /// Public OpenAPI projection. + pub const OPENAPI: &str = "/openapi.json"; + /// Registry service metadata. + pub const SERVICE: &str = "/v2"; + /// Registry resource collection. + pub const RESOURCES: &str = "/v2/resources"; + /// Registry resource metadata. + pub const RESOURCE: &str = "/v2/resources/{resource}"; + /// Consultation List operation. + pub const RECORDS: &str = "/v2/resources/{resource}/records"; + /// Consultation Retrieve operation. + pub const RECORD: &str = "/v2/resources/{resource}/records/{record_identifier}"; + /// Consultation Lookup operation. + pub const LOOKUP: &str = "/v2/resources/{resource}/lookups/{lookup}"; + /// Consultation Search operation. + pub const SEARCH: &str = "/v2/resources/{resource}/searches/{search}"; + /// Generated artifact retrieval. + pub const ARTIFACT: &str = "/v2/artifacts/{artifact_identifier}"; + /// SDMX Aggregate Data with an explicit key. + pub const SDMX_DATA_KEY: &str = "/sdmx/v2/data/{context}/{agency}/{resource}/{version}/{key}"; + /// SDMX Aggregate Data with an omitted key. + pub const SDMX_DATA: &str = "/sdmx/v2/data/{context}/{agency}/{resource}/{version}"; + /// SDMX structure retrieval. + pub const SDMX_STRUCTURE: &str = + "/sdmx/v2/structure/{artefact_type}/{agency}/{resource}/{version}"; + + /// Complete fixed Relay V2 route inventory, in router registration order. + pub const ALL: &[&str] = &[ + HEALTH, + READY, + OPENAPI, + SERVICE, + RESOURCES, + RESOURCE, + RECORDS, + RECORD, + LOOKUP, + SEARCH, + ARTIFACT, + SDMX_DATA_KEY, + SDMX_DATA, + SDMX_STRUCTURE, + ]; +} + +macro_rules! define_problem_codes { + ($( + $variant:ident => { + code: $code:literal, + title: $title:literal, + status: $status:literal, + detail: $detail:literal, + type_uri: $type_uri:literal + } + ),+ $(,)?) => { + /// Closed public failure classes for the Relay V2 HTTP boundary. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum ProblemCode { + $($variant),+ + } + + impl ProblemCode { + /// Complete public problem inventory in stable catalog order. + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + #[must_use] + pub const fn code(self) -> &'static str { + match self { + $(Self::$variant => $code),+ + } + } + + #[must_use] + pub const fn title(self) -> &'static str { + match self { + $(Self::$variant => $title),+ + } + } + + #[must_use] + pub const fn status(self) -> u16 { + match self { + $(Self::$variant => $status),+ + } + } + + #[must_use] + pub const fn detail(self) -> &'static str { + match self { + $(Self::$variant => $detail),+ + } + } + + #[must_use] + pub const fn type_uri(self) -> &'static str { + match self { + $(Self::$variant => $type_uri),+ + } + } + } + }; +} + +define_problem_codes! { + ConsultationInvalidRequest => { code: "consultation.invalid_request", title: "Consultation request is invalid", status: 400, detail: "the consultation request is invalid", type_uri: "https://id.registrystack.org/problems/registry-relay/consultation/invalid_request" }, + AggregateDataInvalidRequest => { code: "aggregate-data.invalid_request", title: "Aggregate data request is invalid", status: 400, detail: "the aggregate data request is invalid", type_uri: "https://id.registrystack.org/problems/registry-relay/aggregate-data/invalid_request" }, + FieldsInvalid => { code: "request.fields_invalid", title: "Field selection is invalid", status: 400, detail: "field selection is invalid", type_uri: "https://id.registrystack.org/problems/registry-relay/request/fields_invalid" }, + UnknownFilter => { code: "filter.unknown_field", title: "Filter is not declared", status: 400, detail: "filter is not declared for this operation", type_uri: "https://id.registrystack.org/problems/registry-relay/filter/unknown_field" }, + InvalidFilter => { code: "filter.invalid_value", title: "Filter value is invalid", status: 400, detail: "filter value is invalid", type_uri: "https://id.registrystack.org/problems/registry-relay/filter/invalid_value" }, + CursorInvalid => { code: "query.cursor_invalid", title: "Cursor is invalid", status: 400, detail: "cursor is invalid for this query", type_uri: "https://id.registrystack.org/problems/registry-relay/query/cursor_invalid" }, + AccessProfileInvalid => { code: "request.access_profile_invalid", title: "Access profile selection is invalid", status: 400, detail: "access profile selection is invalid", type_uri: "https://id.registrystack.org/problems/registry-relay/request/access_profile_invalid" }, + MissingCredential => { code: "auth.missing_credential", title: "Bearer access token is required", status: 401, detail: "a bearer access token is required", type_uri: "https://id.registrystack.org/problems/registry-relay/auth/missing_credential" }, + InvalidCredential => { code: "auth.invalid_credential", title: "Bearer access token is invalid", status: 401, detail: "bearer access token validation failed", type_uri: "https://id.registrystack.org/problems/registry-relay/auth/invalid_credential" }, + ConsultationDenied => { code: "consultation.denied", title: "Consultation is not permitted", status: 403, detail: "the consultation is not permitted", type_uri: "https://id.registrystack.org/problems/registry-relay/consultation/denied" }, + AggregateDataDenied => { code: "aggregate-data.denied", title: "Aggregate data access is not permitted", status: 403, detail: "aggregate data access is not permitted", type_uri: "https://id.registrystack.org/problems/registry-relay/aggregate-data/denied" }, + ResourceNotFound => { code: "resource.not_found", title: "Requested resource was not found", status: 404, detail: "the requested resource was not found", type_uri: "https://id.registrystack.org/problems/registry-relay/resource/not_found" }, + ConsultationUnresolved => { code: "consultation.unresolved", title: "Requested record was not resolved", status: 404, detail: "the requested record was not resolved", type_uri: "https://id.registrystack.org/problems/registry-relay/consultation/unresolved" }, + UnsupportedFormat => { code: "format.unsupported", title: "Requested format is not supported", status: 406, detail: "the requested format is not supported", type_uri: "https://id.registrystack.org/problems/registry-relay/format/unsupported" }, + BodyTooLarge => { code: "internal.payload_too_large", title: "Request body is too large", status: 413, detail: "request body exceeds the configured limit", type_uri: "https://id.registrystack.org/problems/registry-relay/internal/payload_too_large" }, + ConsultationResponseTooLarge => { code: "consultation.response_too_large", title: "Consultation response is too large", status: 413, detail: "the consultation response exceeds the configured limit", type_uri: "https://id.registrystack.org/problems/registry-relay/consultation/response_too_large" }, + AggregateDataTooLarge => { code: "aggregate-data.too_large", title: "Aggregate data request is too broad", status: 413, detail: "the aggregate data request exceeds its observation limit", type_uri: "https://id.registrystack.org/problems/registry-relay/aggregate-data/too_large" }, + UriTooLong => { code: "internal.uri_too_long", title: "Request URI is too long", status: 414, detail: "request URI exceeds the configured limit", type_uri: "https://id.registrystack.org/problems/registry-relay/internal/uri_too_long" }, + UnsupportedMediaType => { code: "request.media_type_unsupported", title: "Request media type is not supported", status: 415, detail: "request body must use application/json", type_uri: "https://id.registrystack.org/problems/registry-relay/request/media_type_unsupported" }, + RateLimited => { code: "consultation.rate_limited", title: "Consultation quota is exhausted", status: 429, detail: "the consultation quota is exhausted", type_uri: "https://id.registrystack.org/problems/registry-relay/consultation/rate_limited" }, + AggregateDataRateLimited => { code: "aggregate-data.rate_limited", title: "Aggregate data quota is exhausted", status: 429, detail: "the aggregate data quota is exhausted", type_uri: "https://id.registrystack.org/problems/registry-relay/aggregate-data/rate_limited" }, + Internal => { code: "internal.unhandled", title: "Request could not be served", status: 500, detail: "the request could not be served", type_uri: "https://id.registrystack.org/problems/registry-relay/internal/unhandled" }, + SourceUnavailable => { code: "source.unavailable", title: "Authoritative source is unavailable", status: 503, detail: "the authoritative source is unavailable", type_uri: "https://id.registrystack.org/problems/registry-relay/source/unavailable" }, + AuditUnavailable => { code: "audit.unavailable", title: "Required audit is unavailable", status: 503, detail: "required audit is unavailable", type_uri: "https://id.registrystack.org/problems/registry-relay/audit/unavailable" }, + ServiceNotReady => { code: "service.not_ready", title: "Service is not ready", status: 503, detail: "the service is not ready", type_uri: "https://id.registrystack.org/problems/registry-relay/service/not_ready" }, + Timeout => { code: "internal.timeout", title: "Request timed out", status: 504, detail: "request exceeded the configured timeout", type_uri: "https://id.registrystack.org/problems/registry-relay/internal/timeout" }, +} + +impl std::fmt::Display for ProblemCode { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.code()) + } +} + +#[cfg(test)] +mod tests { + use super::{routes, ProblemCode, PROBLEM_MEDIA_TYPE}; + + #[test] + fn fixed_route_inventory_is_exact() { + assert_eq!( + routes::ALL, + [ + "/health", + "/ready", + "/openapi.json", + "/v2", + "/v2/resources", + "/v2/resources/{resource}", + "/v2/resources/{resource}/records", + "/v2/resources/{resource}/records/{record_identifier}", + "/v2/resources/{resource}/lookups/{lookup}", + "/v2/resources/{resource}/searches/{search}", + "/v2/artifacts/{artifact_identifier}", + "/sdmx/v2/data/{context}/{agency}/{resource}/{version}/{key}", + "/sdmx/v2/data/{context}/{agency}/{resource}/{version}", + "/sdmx/v2/structure/{artefact_type}/{agency}/{resource}/{version}", + ] + ); + } + + #[test] + fn problem_catalog_uses_the_exact_stable_identifier_uris() { + assert_eq!(PROBLEM_MEDIA_TYPE, "application/problem+json"); + assert_eq!( + ProblemCode::ALL + .iter() + .copied() + .map(|problem| (problem.code(), problem.type_uri())) + .collect::>(), + vec![ + ("consultation.invalid_request", "https://id.registrystack.org/problems/registry-relay/consultation/invalid_request"), + ("aggregate-data.invalid_request", "https://id.registrystack.org/problems/registry-relay/aggregate-data/invalid_request"), + ("request.fields_invalid", "https://id.registrystack.org/problems/registry-relay/request/fields_invalid"), + ("filter.unknown_field", "https://id.registrystack.org/problems/registry-relay/filter/unknown_field"), + ("filter.invalid_value", "https://id.registrystack.org/problems/registry-relay/filter/invalid_value"), + ("query.cursor_invalid", "https://id.registrystack.org/problems/registry-relay/query/cursor_invalid"), + ("request.access_profile_invalid", "https://id.registrystack.org/problems/registry-relay/request/access_profile_invalid"), + ("auth.missing_credential", "https://id.registrystack.org/problems/registry-relay/auth/missing_credential"), + ("auth.invalid_credential", "https://id.registrystack.org/problems/registry-relay/auth/invalid_credential"), + ("consultation.denied", "https://id.registrystack.org/problems/registry-relay/consultation/denied"), + ("aggregate-data.denied", "https://id.registrystack.org/problems/registry-relay/aggregate-data/denied"), + ("resource.not_found", "https://id.registrystack.org/problems/registry-relay/resource/not_found"), + ("consultation.unresolved", "https://id.registrystack.org/problems/registry-relay/consultation/unresolved"), + ("format.unsupported", "https://id.registrystack.org/problems/registry-relay/format/unsupported"), + ("internal.payload_too_large", "https://id.registrystack.org/problems/registry-relay/internal/payload_too_large"), + ("consultation.response_too_large", "https://id.registrystack.org/problems/registry-relay/consultation/response_too_large"), + ("aggregate-data.too_large", "https://id.registrystack.org/problems/registry-relay/aggregate-data/too_large"), + ("internal.uri_too_long", "https://id.registrystack.org/problems/registry-relay/internal/uri_too_long"), + ("request.media_type_unsupported", "https://id.registrystack.org/problems/registry-relay/request/media_type_unsupported"), + ("consultation.rate_limited", "https://id.registrystack.org/problems/registry-relay/consultation/rate_limited"), + ("aggregate-data.rate_limited", "https://id.registrystack.org/problems/registry-relay/aggregate-data/rate_limited"), + ("internal.unhandled", "https://id.registrystack.org/problems/registry-relay/internal/unhandled"), + ("source.unavailable", "https://id.registrystack.org/problems/registry-relay/source/unavailable"), + ("audit.unavailable", "https://id.registrystack.org/problems/registry-relay/audit/unavailable"), + ("service.not_ready", "https://id.registrystack.org/problems/registry-relay/service/not_ready"), + ("internal.timeout", "https://id.registrystack.org/problems/registry-relay/internal/timeout"), + ] + ); + } + + #[test] + fn problem_catalog_metadata_is_complete_and_value_free() { + for problem in ProblemCode::ALL { + assert!(!problem.title().is_empty()); + assert!((400..=599).contains(&problem.status())); + assert!(!problem.detail().is_empty()); + assert!(problem + .type_uri() + .starts_with("https://id.registrystack.org/problems/registry-relay/")); + } + } +} diff --git a/crates/registry-relay-v2/Cargo.toml b/crates/registry-relay-v2/Cargo.toml index 678195190..0cfdbe7cd 100644 --- a/crates/registry-relay-v2/Cargo.toml +++ b/crates/registry-relay-v2/Cargo.toml @@ -39,9 +39,10 @@ registry-platform-authcommon.workspace = true registry-platform-buildinfo.workspace = true registry-platform-canonical-json.workspace = true registry-platform-config.workspace = true -registry-platform-httpsec.workspace = true +registry-platform-httpsec = { workspace = true, features = ["server"] } registry-platform-oidc.workspace = true registry-platform-sqlite.workspace = true +registry-relay-http-contract.workspace = true reqwest.workspace = true rustix.workspace = true schemars = { workspace = true, optional = true } @@ -68,6 +69,7 @@ oxjsonld = "0.2.5" registry-platform-httputil.workspace = true registry-platform-sqlite = { workspace = true, features = ["fixture"] } registry-platform-testing.workspace = true +registry-relay-client.workspace = true tempfile.workspace = true utoipa.workspace = true diff --git a/crates/registry-relay-v2/examples/problem-catalog.rs b/crates/registry-relay-v2/examples/problem-catalog.rs index 86da1b619..317df86c6 100644 --- a/crates/registry-relay-v2/examples/problem-catalog.rs +++ b/crates/registry-relay-v2/examples/problem-catalog.rs @@ -14,7 +14,7 @@ struct ProblemCatalog<'a> { #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ProblemEntry<'a> { - uri: String, + uri: &'a str, code: &'a str, title: &'a str, description: &'a str, diff --git a/crates/registry-relay-v2/src/api.rs b/crates/registry-relay-v2/src/api.rs index 54e25b398..b7b84b0e0 100644 --- a/crates/registry-relay-v2/src/api.rs +++ b/crates/registry-relay-v2/src/api.rs @@ -37,7 +37,7 @@ use crate::model::{ CompiledAccess, CompiledAccessProfile, CompiledOperation, CompiledResource, CompiledStatisticalDataset, OperationKind, RowAuthoritySource, POINT_BBOX_PREDICATE, }; -use crate::problem::{ProblemCode, TraceContext}; +use crate::problem::{ProblemCode, ProblemCodeResponseExt, TraceContext}; use crate::sdmx::{DATA_CSV_MEDIA_TYPE, DATA_JSON_MEDIA_TYPE, STRUCTURE_JSON_MEDIA_TYPE}; use crate::server::{uri_within_bound, RelayService}; use crate::sqlite_runtime::{OperationQuery, PointBbox, SourceRevision, SqliteRuntimeError}; diff --git a/crates/registry-relay-v2/src/artifacts.rs b/crates/registry-relay-v2/src/artifacts.rs index d94ddeca7..3c11d9a8c 100644 --- a/crates/registry-relay-v2/src/artifacts.rs +++ b/crates/registry-relay-v2/src/artifacts.rs @@ -4,6 +4,7 @@ use std::collections::BTreeSet; use registry_platform_canonical_json::canonicalize_json; +use registry_relay_http_contract::{routes, PROBLEM_MEDIA_TYPE}; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use sha2::{Digest, Sha256}; @@ -625,25 +626,26 @@ fn push_text( fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { let mut paths = Map::new(); for (path, operation_id, description) in [ - ("/health", "relay.health", "Relay process liveness"), - ("/ready", "relay.ready", "Compiled Registry readiness"), + (routes::HEALTH, "relay.health", "Relay process liveness"), + (routes::READY, "relay.ready", "Compiled Registry readiness"), ( - "/openapi.json", + routes::OPENAPI, "relay.openapi.public", "Safe public OpenAPI projection", ), ( - "/v2", + routes::SERVICE, "relay.registry.metadata", "Registry service metadata", ), ] { - let cacheable = path == "/openapi.json" - || (path == "/v2" && registry.metadata_visibility.resources == Visibility::Public); + let cacheable = path == routes::OPENAPI + || (path == routes::SERVICE + && registry.metadata_visibility.resources == Visibility::Public); let schema = match path { - "/health" | "/ready" => json!({"$ref": "#/components/schemas/Status"}), - "/v2" => json!({"$ref": "#/components/schemas/ServiceMetadata"}), - "/openapi.json" => json!({"type": "object"}), + routes::HEALTH | routes::READY => json!({"$ref": "#/components/schemas/Status"}), + routes::SERVICE => json!({"$ref": "#/components/schemas/ServiceMetadata"}), + routes::OPENAPI => json!({"type": "object"}), _ => unreachable!("fixed OpenAPI path"), }; let mut responses = json!({ @@ -680,7 +682,7 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { add_not_modified_response(&mut retrieve_responses); } paths.insert( - "/v2/resources".into(), + routes::RESOURCES.into(), json!({"get": { "operationId": "relay.resources.list", "security": if registry.metadata_visibility.resources == Visibility::Public { @@ -702,7 +704,7 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { }}), ); paths.insert( - "/v2/resources/{resource}".into(), + routes::RESOURCE.into(), json!({"get": { "operationId": "relay.resources.retrieve", "security": if registry.metadata_visibility.resources == Visibility::Public { @@ -1128,7 +1130,7 @@ fn openapi(registry: &CompiledRegistry, public_only: bool) -> Value { }, "Problem": { "description": "Registry Stack problem", - "content": {"application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}}} + "content": {(PROBLEM_MEDIA_TYPE): {"schema": {"$ref": "#/components/schemas/Problem"}}} } } } diff --git a/crates/registry-relay-v2/src/problem.rs b/crates/registry-relay-v2/src/problem.rs index 4ecc7aef3..9d50e815a 100644 --- a/crates/registry-relay-v2/src/problem.rs +++ b/crates/registry-relay-v2/src/problem.rs @@ -9,98 +9,24 @@ use axum::body::Body; use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, WWW_AUTHENTICATE}; use axum::http::{HeaderValue, Response, StatusCode}; pub use registry_platform_httpsec::{ProblemBody, TraceContext, TraceId}; +pub use registry_relay_http_contract::ProblemCode; +use registry_relay_http_contract::PROBLEM_MEDIA_TYPE; -const PROBLEM_BASE: &str = "https://id.registrystack.org/problems/registry-relay/"; - -macro_rules! define_problem_codes { - ($( - $variant:ident => { - code: $code:literal, - title: $title:literal, - status: $status:literal, - detail: $detail:literal - } - ),+ $(,)?) => { - /// Closed public failure classes for the V2 HTTP boundary. - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - pub enum ProblemCode { - $($variant),+ - } - - impl ProblemCode { - /// Complete inventory used to generate the public identifier catalog. - pub const ALL: &'static [Self] = &[$(Self::$variant),+]; - - #[must_use] - pub const fn code(self) -> &'static str { - match self { - $(Self::$variant => $code),+ - } - } - - #[must_use] - pub const fn title(self) -> &'static str { - match self { - $(Self::$variant => $title),+ - } - } - - #[must_use] - pub const fn status(self) -> u16 { - match self { - $(Self::$variant => $status),+ - } - } - - #[must_use] - pub const fn detail(self) -> &'static str { - match self { - $(Self::$variant => $detail),+ - } - } - } - }; -} - -define_problem_codes! { - ConsultationInvalidRequest => { code: "consultation.invalid_request", title: "Consultation request is invalid", status: 400, detail: "the consultation request is invalid" }, - AggregateDataInvalidRequest => { code: "aggregate-data.invalid_request", title: "Aggregate data request is invalid", status: 400, detail: "the aggregate data request is invalid" }, - FieldsInvalid => { code: "request.fields_invalid", title: "Field selection is invalid", status: 400, detail: "field selection is invalid" }, - UnknownFilter => { code: "filter.unknown_field", title: "Filter is not declared", status: 400, detail: "filter is not declared for this operation" }, - InvalidFilter => { code: "filter.invalid_value", title: "Filter value is invalid", status: 400, detail: "filter value is invalid" }, - CursorInvalid => { code: "query.cursor_invalid", title: "Cursor is invalid", status: 400, detail: "cursor is invalid for this query" }, - AccessProfileInvalid => { code: "request.access_profile_invalid", title: "Access profile selection is invalid", status: 400, detail: "access profile selection is invalid" }, - MissingCredential => { code: "auth.missing_credential", title: "Bearer access token is required", status: 401, detail: "a bearer access token is required" }, - InvalidCredential => { code: "auth.invalid_credential", title: "Bearer access token is invalid", status: 401, detail: "bearer access token validation failed" }, - ConsultationDenied => { code: "consultation.denied", title: "Consultation is not permitted", status: 403, detail: "the consultation is not permitted" }, - AggregateDataDenied => { code: "aggregate-data.denied", title: "Aggregate data access is not permitted", status: 403, detail: "aggregate data access is not permitted" }, - ResourceNotFound => { code: "resource.not_found", title: "Requested resource was not found", status: 404, detail: "the requested resource was not found" }, - ConsultationUnresolved => { code: "consultation.unresolved", title: "Requested record was not resolved", status: 404, detail: "the requested record was not resolved" }, - UnsupportedFormat => { code: "format.unsupported", title: "Requested format is not supported", status: 406, detail: "the requested format is not supported" }, - BodyTooLarge => { code: "internal.payload_too_large", title: "Request body is too large", status: 413, detail: "request body exceeds the configured limit" }, - ConsultationResponseTooLarge => { code: "consultation.response_too_large", title: "Consultation response is too large", status: 413, detail: "the consultation response exceeds the configured limit" }, - AggregateDataTooLarge => { code: "aggregate-data.too_large", title: "Aggregate data request is too broad", status: 413, detail: "the aggregate data request exceeds its observation limit" }, - UriTooLong => { code: "internal.uri_too_long", title: "Request URI is too long", status: 414, detail: "request URI exceeds the configured limit" }, - UnsupportedMediaType => { code: "request.media_type_unsupported", title: "Request media type is not supported", status: 415, detail: "request body must use application/json" }, - RateLimited => { code: "consultation.rate_limited", title: "Consultation quota is exhausted", status: 429, detail: "the consultation quota is exhausted" }, - AggregateDataRateLimited => { code: "aggregate-data.rate_limited", title: "Aggregate data quota is exhausted", status: 429, detail: "the aggregate data quota is exhausted" }, - Internal => { code: "internal.unhandled", title: "Request could not be served", status: 500, detail: "the request could not be served" }, - SourceUnavailable => { code: "source.unavailable", title: "Authoritative source is unavailable", status: 503, detail: "the authoritative source is unavailable" }, - AuditUnavailable => { code: "audit.unavailable", title: "Required audit is unavailable", status: 503, detail: "required audit is unavailable" }, - ServiceNotReady => { code: "service.not_ready", title: "Service is not ready", status: 503, detail: "the service is not ready" }, - Timeout => { code: "internal.timeout", title: "Request timed out", status: 504, detail: "request exceeded the configured timeout" }, -} - -impl ProblemCode { +/// Relay-runtime response construction for the shared public problem catalog. +pub trait ProblemCodeResponseExt { #[must_use] - pub fn type_uri(self) -> String { - format!("{PROBLEM_BASE}{}", self.code().replace('.', "/")) - } + fn body(self, trace_id: TraceId) -> ProblemBody; + /// Serialize the one fixed public failure body and attach the effective + /// W3C trace context. No rejected value is accepted by this API. #[must_use] - pub fn body(self, trace_id: TraceId) -> ProblemBody { + fn response(self, trace: &TraceContext) -> Response; +} + +impl ProblemCodeResponseExt for ProblemCode { + fn body(self, trace_id: TraceId) -> ProblemBody { ProblemBody { - type_uri: self.type_uri(), + type_uri: self.type_uri().to_owned(), title: self.title(), status: self.status(), detail: self.detail(), @@ -109,20 +35,14 @@ impl ProblemCode { } } - /// Serialize the one fixed public failure body and attach the effective - /// W3C trace context. No rejected value is accepted by this API. - #[must_use] - pub fn response(self, trace: &TraceContext) -> Response { + fn response(self, trace: &TraceContext) -> Response { let bytes = serde_json::to_vec(&self.body(trace.trace_id.clone())) .unwrap_or_else(|_| b"{}".to_vec()); let mut response = Response::new(Body::from(bytes)); *response.status_mut() = StatusCode::from_u16(self.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); let headers = response.headers_mut(); - headers.insert( - CONTENT_TYPE, - HeaderValue::from_static("application/problem+json"), - ); + headers.insert(CONTENT_TYPE, HeaderValue::from_static(PROBLEM_MEDIA_TYPE)); headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); if matches!(self, Self::MissingCredential | Self::InvalidCredential) { headers.insert( diff --git a/crates/registry-relay-v2/src/sdmx_http.rs b/crates/registry-relay-v2/src/sdmx_http.rs index 34081dde4..6e6477571 100644 --- a/crates/registry-relay-v2/src/sdmx_http.rs +++ b/crates/registry-relay-v2/src/sdmx_http.rs @@ -29,7 +29,7 @@ use crate::contract::Handling; use crate::model::{ CompiledAccess, CompiledRegistry, CompiledStatisticalDataset, RowAuthoritySource, }; -use crate::problem::{ProblemCode, TraceContext}; +use crate::problem::{ProblemCode, ProblemCodeResponseExt, TraceContext}; use crate::sdmx::{ parse_data_query, serialize_data_csv, serialize_data_json, DataQueryError, DimensionAtObservation, RepresentationError, StatisticalRow, StatisticalValue, diff --git a/crates/registry-relay-v2/src/server.rs b/crates/registry-relay-v2/src/server.rs index 30eee9693..e15d1e3cd 100644 --- a/crates/registry-relay-v2/src/server.rs +++ b/crates/registry-relay-v2/src/server.rs @@ -9,6 +9,7 @@ use axum::routing::{get, post}; use axum::Router; use axum::{body::Body, http::Request}; use registry_platform_httpsec::{security_headers, CspBuilder}; +use registry_relay_http_contract::routes; use tower_http::trace::TraceLayer; use crate::artifacts::ArtifactSet; @@ -110,47 +111,20 @@ impl RelayService { /// compiler-confined by handler dispatch against the immutable model. pub fn router(service: Arc) -> Router { Router::new() - .route("/health", get(crate::api::health)) - .route("/ready", get(crate::api::ready)) - .route("/openapi.json", get(crate::api::openapi)) - .route("/v2", get(crate::api::service_metadata)) - .route("/v2/resources", get(crate::api::resource_list)) - .route( - "/v2/resources/{resource}", - get(crate::api::resource_metadata), - ) - .route( - "/v2/resources/{resource}/records", - get(crate::api::record_list), - ) - .route( - "/v2/resources/{resource}/records/{record_identifier}", - get(crate::api::record_read), - ) - .route( - "/v2/resources/{resource}/lookups/{lookup}", - post(crate::api::record_lookup), - ) - .route( - "/v2/resources/{resource}/searches/{search}", - get(crate::api::record_search), - ) - .route( - "/v2/artifacts/{artifact_identifier}", - get(crate::api::artifact), - ) - .route( - "/sdmx/v2/data/{context}/{agency}/{resource}/{version}/{key}", - get(crate::sdmx_http::data_keyed), - ) - .route( - "/sdmx/v2/data/{context}/{agency}/{resource}/{version}", - get(crate::sdmx_http::data_omitted_key), - ) - .route( - "/sdmx/v2/structure/{artefact_type}/{agency}/{resource}/{version}", - get(crate::sdmx_http::structure), - ) + .route(routes::HEALTH, get(crate::api::health)) + .route(routes::READY, get(crate::api::ready)) + .route(routes::OPENAPI, get(crate::api::openapi)) + .route(routes::SERVICE, get(crate::api::service_metadata)) + .route(routes::RESOURCES, get(crate::api::resource_list)) + .route(routes::RESOURCE, get(crate::api::resource_metadata)) + .route(routes::RECORDS, get(crate::api::record_list)) + .route(routes::RECORD, get(crate::api::record_read)) + .route(routes::LOOKUP, post(crate::api::record_lookup)) + .route(routes::SEARCH, get(crate::api::record_search)) + .route(routes::ARTIFACT, get(crate::api::artifact)) + .route(routes::SDMX_DATA_KEY, get(crate::sdmx_http::data_keyed)) + .route(routes::SDMX_DATA, get(crate::sdmx_http::data_omitted_key)) + .route(routes::SDMX_STRUCTURE, get(crate::sdmx_http::structure)) .fallback(crate::api::not_found) .method_not_allowed_fallback(crate::api::not_found) .with_state(service) @@ -212,15 +186,15 @@ fn operational_route(uri: &http::Uri) -> &'static str { return "unmatched"; } match parts { - (Some("health"), None, None, None, None, None, None, None) => "/health", - (Some("ready"), None, None, None, None, None, None, None) => "/ready", - (Some("openapi.json"), None, None, None, None, None, None, None) => "/openapi.json", - (Some("v2"), None, None, None, None, None, None, None) => "/v2", - (Some("v2"), Some("resources"), None, None, None, None, None, None) => "/v2/resources", + (Some("health"), None, None, None, None, None, None, None) => routes::HEALTH, + (Some("ready"), None, None, None, None, None, None, None) => routes::READY, + (Some("openapi.json"), None, None, None, None, None, None, None) => routes::OPENAPI, + (Some("v2"), None, None, None, None, None, None, None) => routes::SERVICE, + (Some("v2"), Some("resources"), None, None, None, None, None, None) => routes::RESOURCES, (Some("v2"), Some("resources"), Some(resource), None, None, None, None, None) if !resource.is_empty() => { - "/v2/resources/{resource}" + routes::RESOURCE } ( Some("v2"), @@ -231,7 +205,7 @@ fn operational_route(uri: &http::Uri) -> &'static str { None, None, None, - ) if !resource.is_empty() => "/v2/resources/{resource}/records", + ) if !resource.is_empty() => routes::RECORDS, ( Some("v2"), Some("resources"), @@ -241,9 +215,7 @@ fn operational_route(uri: &http::Uri) -> &'static str { None, None, None, - ) if !resource.is_empty() && !record_identifier.is_empty() => { - "/v2/resources/{resource}/records/{record_identifier}" - } + ) if !resource.is_empty() && !record_identifier.is_empty() => routes::RECORD, ( Some("v2"), Some("resources"), @@ -253,9 +225,7 @@ fn operational_route(uri: &http::Uri) -> &'static str { None, None, None, - ) if !resource.is_empty() && !lookup.is_empty() => { - "/v2/resources/{resource}/lookups/{lookup}" - } + ) if !resource.is_empty() && !lookup.is_empty() => routes::LOOKUP, ( Some("v2"), Some("resources"), @@ -265,9 +235,7 @@ fn operational_route(uri: &http::Uri) -> &'static str { None, None, None, - ) if !resource.is_empty() && !search.is_empty() => { - "/v2/resources/{resource}/searches/{search}" - } + ) if !resource.is_empty() && !search.is_empty() => routes::SEARCH, ( Some("v2"), Some("artifacts"), @@ -277,7 +245,7 @@ fn operational_route(uri: &http::Uri) -> &'static str { None, None, None, - ) if !artifact_identifier.is_empty() => "/v2/artifacts/{artifact_identifier}", + ) if !artifact_identifier.is_empty() => routes::ARTIFACT, ( Some("sdmx"), Some("v2"), @@ -292,7 +260,7 @@ fn operational_route(uri: &http::Uri) -> &'static str { && !resource.is_empty() && !version.is_empty() => { - "/sdmx/v2/data/{context}/{agency}/{resource}/{version}" + routes::SDMX_DATA } ( Some("sdmx"), @@ -309,7 +277,7 @@ fn operational_route(uri: &http::Uri) -> &'static str { && !version.is_empty() && !key.is_empty() => { - "/sdmx/v2/data/{context}/{agency}/{resource}/{version}/{key}" + routes::SDMX_DATA_KEY } ( Some("sdmx"), @@ -325,7 +293,7 @@ fn operational_route(uri: &http::Uri) -> &'static str { && !resource.is_empty() && !version.is_empty() => { - "/sdmx/v2/structure/{artefact_type}/{agency}/{resource}/{version}" + routes::SDMX_STRUCTURE } _ => "unmatched", } diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index 891281687..9654c3fb9 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -27,6 +27,12 @@ use registry_platform_sqlite::{ use registry_platform_testing::{ fixtures, oidc_verifier_config, sign_ed25519_compact_jwt, MockIdp, }; +use registry_relay_client::{ + BoundingBox, CollectionRequest, Conditional, LookupRequest, RecordCollectionResponse, + RecordFormat, RecordOptions, RecordResponse, RelayClient, RelayClientConfig, + ResourceListRequest, SdmxDataFormat, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, + StaticToken, TokenProvider, +}; use registry_relay_v2::artifacts::generate_artifacts; use registry_relay_v2::audit::RelayAudit; use registry_relay_v2::auth::RelayAuthenticator; @@ -86,6 +92,64 @@ struct ProjectHarness { _temp: TempDir, } +struct ClientLoopback { + client: RelayClient, + shutdown: tokio::sync::oneshot::Sender<()>, + server: tokio::task::JoinHandle>, +} + +impl ClientLoopback { + async fn start(harness: &ProjectHarness, token: Option) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("client acceptance listener binds"); + let address = listener + .local_addr() + .expect("client acceptance address resolves"); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let app = harness.app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + }); + let mut config = RelayClientConfig::new( + url::Url::parse(&format!("http://{address}")) + .expect("client acceptance base URL parses"), + ); + if let Some(token) = token { + let provider: Arc = + Arc::new(StaticToken::new(token).expect("fixture bearer token is header-safe")); + config = config.with_token_provider(provider); + } + Self { + client: RelayClient::new(config).expect("client acceptance client builds"), + shutdown, + server, + } + } + + async fn stop(self) { + self.shutdown + .send(()) + .expect("client acceptance server is running"); + tokio::time::timeout(Duration::from_secs(5), self.server) + .await + .expect("client acceptance server shuts down before timeout") + .expect("client acceptance server task completes") + .expect("client acceptance server shuts down cleanly"); + } +} + +fn complete(outcome: Conditional, operation: &str) -> registry_relay_client::Complete { + match outcome { + Conditional::Complete(value) => value, + Conditional::NotModified(_) => panic!("{operation} unexpectedly returned 304"), + } +} + struct ControlledAuditSink { fail_on_write: usize, writes: AtomicUsize, @@ -307,6 +371,377 @@ async fn all_four_registry_http_journeys_use_the_real_router() { } } +#[tokio::test] +async fn rust_client_drives_the_real_relay_router_across_the_public_surface() { + let mut business = ProjectHarness::open("business-registry").await; + let business_loopback = ClientLoopback::start(&business, None).await; + let client = &business_loopback.client; + + assert_eq!( + client.health().await.expect("health succeeds").value.status, + "ok" + ); + assert_eq!( + client.ready().await.expect("ready succeeds").value.status, + "ready" + ); + let openapi = complete( + client.openapi(None).await.expect("OpenAPI succeeds"), + "OpenAPI", + ); + assert_eq!(openapi.value.media_type(), "application/json"); + assert!(!openapi.value.as_bytes().is_empty()); + + let service = complete( + client + .service_metadata(None) + .await + .expect("service metadata succeeds"), + "service metadata", + ); + assert_eq!( + service.value.registry_identifier, + "urn:example:registry:registered-businesses" + ); + + let first_resources = complete( + client + .resources( + ResourceListRequest::default() + .page_size(1) + .expect("resource page size is valid"), + None, + ) + .await + .expect("first resource page succeeds"), + "first resource page", + ); + assert_eq!(first_resources.value.value.items.len(), 1); + let resource_continuation = first_resources + .value + .continuation + .as_ref() + .expect("first resource page has a continuation"); + let second_resources = complete( + client + .continue_resources(resource_continuation, None) + .await + .expect("second resource page succeeds"), + "second resource page", + ); + assert_eq!(second_resources.value.value.items.len(), 1); + assert_ne!( + first_resources.value.value.items[0].resource_identifier, + second_resources.value.value.items[0].resource_identifier + ); + let resource = complete( + client + .resource("registered-business", None) + .await + .expect("resource metadata succeeds"), + "resource metadata", + ); + assert_eq!( + resource.value.data.resource_identifier, + "registered-business" + ); + + let first_list_request = CollectionRequest::default() + .page_size(1) + .expect("record page size is valid") + .filter("jurisdiction", "EX-A") + .expect("declared filter is valid"); + let first_list = complete( + client + .list_records("registered-business", &first_list_request, None) + .await + .expect("first Record page succeeds"), + "first Record page", + ); + match &first_list.value.value { + RecordCollectionResponse::Json(records) => assert_eq!(records.items.len(), 1), + RecordCollectionResponse::GeoJson(_) => panic!("list unexpectedly returned GeoJSON"), + } + let list_continuation = first_list + .value + .continuation + .as_ref() + .expect("first Record page has a continuation"); + let second_list = complete( + client + .continue_collection(list_continuation, None) + .await + .expect("second Record page succeeds"), + "second Record page", + ); + match &second_list.value.value { + RecordCollectionResponse::Json(records) => assert_eq!(records.items.len(), 1), + RecordCollectionResponse::GeoJson(_) => { + panic!("continuation unexpectedly returned GeoJSON") + } + } + + let read = complete( + client + .read_record( + "registered-business", + "BIZ-SYNTH-0001", + &RecordOptions::default(), + None, + ) + .await + .expect("Record read succeeds"), + "Record read", + ); + match &read.value { + RecordResponse::Json(record) => { + assert_eq!(record.data.record_identifier, "BIZ-SYNTH-0001") + } + RecordResponse::GeoJson(_) => panic!("ordinary read unexpectedly returned GeoJSON"), + } + let etag = read + .metadata + .etag() + .cloned() + .expect("public snapshot read returns an ETag"); + match client + .read_record( + "registered-business", + "BIZ-SYNTH-0001", + &RecordOptions::default(), + Some(&etag), + ) + .await + .expect("Record revalidation succeeds") + { + Conditional::NotModified(not_modified) => assert_eq!(not_modified.etag, etag), + Conditional::Complete(_) => panic!("Record revalidation did not return 304"), + } + let json_ld_read = complete( + client + .read_record( + "registered-business", + "BIZ-SYNTH-0001", + &RecordOptions::default().format(RecordFormat::JsonLd), + None, + ) + .await + .expect("JSON-LD Record read succeeds"), + "JSON-LD Record read", + ); + match json_ld_read.value { + RecordResponse::Json(record) => { + assert_eq!(record.data.record_identifier, "BIZ-SYNTH-0001"); + assert!(record.json_ld_context.is_some()); + } + RecordResponse::GeoJson(_) => panic!("JSON-LD read unexpectedly returned GeoJSON"), + } + + let feature_read = complete( + client + .read_record( + "registered-premises", + "PREM-SYNTH-0001", + &RecordOptions::default().format(RecordFormat::GeoJsonRfc7946), + None, + ) + .await + .expect("GeoJSON feature read succeeds"), + "GeoJSON feature read", + ); + match feature_read.value { + RecordResponse::GeoJson(feature) => { + assert_eq!(feature.kind, "Feature"); + assert_eq!(feature.properties.record_identifier, "PREM-SYNTH-0001"); + } + RecordResponse::Json(_) => panic!("GeoJSON feature read returned ordinary JSON"), + } + + let spatial_request = CollectionRequest::default() + .options(RecordOptions::default().format(RecordFormat::GeoJsonRfc7946)) + .bbox(BoundingBox::new(100.0, 13.0, 101.0, 14.0).expect("fixture bbox is valid")); + let spatial = complete( + client + .search_records("registered-premises", "within-bbox", &spatial_request, None) + .await + .expect("GeoJSON search succeeds"), + "GeoJSON search", + ); + match &spatial.value.value { + RecordCollectionResponse::GeoJson(features) => { + assert_eq!(features.kind, "FeatureCollection"); + assert!(!features.features.is_empty()); + } + RecordCollectionResponse::Json(_) => panic!("GeoJSON search returned ordinary JSON"), + } + let spatial_continuation = spatial + .value + .continuation + .as_ref() + .expect("first GeoJSON search page has a continuation"); + let next_spatial = complete( + client + .continue_collection(spatial_continuation, None) + .await + .expect("second GeoJSON search page succeeds"), + "second GeoJSON search page", + ); + match &next_spatial.value.value { + RecordCollectionResponse::GeoJson(features) => { + assert_eq!(features.kind, "FeatureCollection"); + assert_eq!(features.features.len(), 1); + } + RecordCollectionResponse::Json(_) => { + panic!("GeoJSON search continuation returned ordinary JSON") + } + } + let json_fg_request = CollectionRequest::default() + .options(RecordOptions::default().format(RecordFormat::JsonFg)) + .bbox(BoundingBox::new(100.0, 13.0, 101.0, 14.0).expect("fixture bbox is valid")); + let json_fg = complete( + client + .search_records("registered-premises", "within-bbox", &json_fg_request, None) + .await + .expect("JSON-FG search succeeds"), + "JSON-FG search", + ); + match json_fg.value.value { + RecordCollectionResponse::GeoJson(features) => { + assert_eq!(features.kind, "FeatureCollection"); + assert!(features.conforms_to.is_some()); + } + RecordCollectionResponse::Json(_) => panic!("JSON-FG search returned ordinary JSON"), + } + + let artifact = complete( + client + .artifact("capability-inventory", None) + .await + .expect("public artifact succeeds"), + "public artifact", + ); + assert_eq!(artifact.value.media_type(), "application/json"); + assert!(!artifact.value.as_bytes().is_empty()); + + business_loopback.stop().await; + if let Some(idp) = business.idp.take() { + idp.stop().await; + } + + let mut civil = ProjectHarness::open("civil-event").await; + let civil_journey = project_journey("civil-event"); + let lookup_authorization = civil_journey + .authorizations + .get("civil-verifier-ex-a") + .expect("civil lookup authorization is declared"); + let lookup_token = civil.token("client-lookup", lookup_authorization); + let civil_loopback = ClientLoopback::start(&civil, Some(lookup_token)).await; + let lookup = LookupRequest::default() + .options( + RecordOptions::default() + .fields([ + "eventType", + "registrationStatus", + "registrationDate", + "certificateAvailable", + ]) + .expect("lookup fields are valid"), + ) + .selector("registrationNumber", json!("REG-SYNTH-000001")) + .expect("registration number selector is valid") + .selector("eventType", json!("BIRTH")) + .expect("event type selector is valid"); + let lookup = complete( + civil_loopback + .client + .lookup_record("civil-event", "verify-registration", &lookup, None) + .await + .expect("lookup succeeds"), + "lookup", + ); + match lookup.value { + RecordResponse::Json(record) => { + assert_eq!(record.data.record_identifier, "EVENT-SYNTH-0001") + } + RecordResponse::GeoJson(_) => panic!("lookup unexpectedly returned GeoJSON"), + } + civil_loopback.stop().await; + if let Some(idp) = civil.idp.take() { + idp.stop().await; + } + + let mut labour = ProjectHarness::open("labour-statistics").await; + let labour_loopback = ClientLoopback::start(&labour, None).await; + let data_request = + SdmxDataRequest::new("LABOUR_STATISTICS", "LABOUR_FORCE_PARTICIPATION", "1.0.0") + .expect("SDMX data route is valid") + .keyed("EX-A.F") + .expect("SDMX key is valid") + .constraint("TIME_PERIOD", "ge:2024-Q1+le:2024-Q2") + .expect("SDMX time constraint is valid") + .dimension_at_observation("AllDimensions") + .expect("SDMX observation dimension is valid"); + let data = complete( + labour_loopback + .client + .sdmx_data(&data_request, None) + .await + .expect("SDMX data succeeds"), + "SDMX data", + ); + assert_eq!( + data.value.media_type(), + "application/vnd.sdmx.data+json;version=2.1.0" + ); + assert!(!data.value.as_bytes().is_empty()); + let csv_request = + SdmxDataRequest::new("LABOUR_STATISTICS", "LABOUR_FORCE_PARTICIPATION", "1.0.0") + .expect("SDMX CSV route is valid") + .keyed("EX-A.F") + .expect("SDMX CSV key is valid") + .constraint("TIME_PERIOD", "ge:2024-Q1+le:2024-Q2") + .expect("SDMX CSV time constraint is valid") + .format(SdmxDataFormat::Csv); + let csv = complete( + labour_loopback + .client + .sdmx_data(&csv_request, None) + .await + .expect("SDMX CSV succeeds"), + "SDMX CSV", + ); + assert_eq!( + csv.value.media_type(), + "application/vnd.sdmx.data+csv;version=2.1.0" + ); + assert!(!csv.value.as_bytes().is_empty()); + + let structure_request = SdmxStructureRequest::new( + SdmxStructureKind::Dataflow, + "LABOUR_STATISTICS", + "LABOUR_FORCE_PARTICIPATION", + "1.0.0", + ) + .expect("SDMX structure route is valid"); + let structure = complete( + labour_loopback + .client + .sdmx_structure(&structure_request, None) + .await + .expect("SDMX structure succeeds"), + "SDMX structure", + ); + assert_eq!( + structure.value.media_type(), + "application/vnd.sdmx.structure+json;version=2.1.0" + ); + assert!(!structure.value.as_bytes().is_empty()); + labour_loopback.stop().await; + if let Some(idp) = labour.idp.take() { + idp.stop().await; + } +} + #[tokio::test] async fn business_list_with_a_late_malformed_row_fails_atomically() { let sink = Arc::new(ControlledAuditSink::new(usize::MAX)); diff --git a/crates/registry-relay/Cargo.toml b/crates/registry-relay/Cargo.toml index f5789b8bd..719a8280b 100644 --- a/crates/registry-relay/Cargo.toml +++ b/crates/registry-relay/Cargo.toml @@ -98,7 +98,7 @@ registry-platform-authcommon = { workspace = true } registry-platform-buildinfo = { workspace = true } registry-platform-audit = { workspace = true } registry-platform-crypto = { workspace = true } -registry-platform-httpsec = { workspace = true } +registry-platform-httpsec = { workspace = true, features = ["server"] } registry-platform-httputil = { workspace = true } registry-platform-oidc = { workspace = true } registry-platform-config = { workspace = true } diff --git a/products/identifiers/contracts/catalog-source.json b/products/identifiers/contracts/catalog-source.json index f4b0c9e90..bb6418b9d 100644 --- a/products/identifiers/contracts/catalog-source.json +++ b/products/identifiers/contracts/catalog-source.json @@ -7,7 +7,7 @@ "status": "active", "compatibilityLine": "relay-v2", "uriPrefix": "https://id.registrystack.org/problems/registry-relay/", - "sourcePath": "crates/registry-relay-v2/src/problem.rs", + "sourcePath": "crates/registry-relay-http-contract/src/lib.rs", "exporterPath": "crates/registry-relay-v2/examples/problem-catalog.rs", "cargoPackage": "registry-relay-v2", "cargoExample": "problem-catalog" diff --git a/products/identifiers/generated/catalog.v1.json b/products/identifiers/generated/catalog.v1.json index b09afd91b..eae69c83c 100644 --- a/products/identifiers/generated/catalog.v1.json +++ b/products/identifiers/generated/catalog.v1.json @@ -214,8 +214,8 @@ "title": "Aggregate data access is not permitted", "description": "aggregate data access is not permitted", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "aggregate-data.denied", @@ -233,8 +233,8 @@ "title": "Aggregate data request is invalid", "description": "the aggregate data request is invalid", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "aggregate-data.invalid_request", @@ -252,8 +252,8 @@ "title": "Aggregate data quota is exhausted", "description": "the aggregate data quota is exhausted", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "aggregate-data.rate_limited", @@ -271,8 +271,8 @@ "title": "Aggregate data request is too broad", "description": "the aggregate data request exceeds its observation limit", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "aggregate-data.too_large", @@ -290,8 +290,8 @@ "title": "Required audit is unavailable", "description": "required audit is unavailable", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "audit.unavailable", @@ -309,8 +309,8 @@ "title": "Bearer access token is invalid", "description": "bearer access token validation failed", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "auth.invalid_credential", @@ -328,8 +328,8 @@ "title": "Bearer access token is required", "description": "a bearer access token is required", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "auth.missing_credential", @@ -347,8 +347,8 @@ "title": "Consultation is not permitted", "description": "the consultation is not permitted", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "consultation.denied", @@ -366,8 +366,8 @@ "title": "Consultation request is invalid", "description": "the consultation request is invalid", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "consultation.invalid_request", @@ -385,8 +385,8 @@ "title": "Consultation quota is exhausted", "description": "the consultation quota is exhausted", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "consultation.rate_limited", @@ -404,8 +404,8 @@ "title": "Consultation response is too large", "description": "the consultation response exceeds the configured limit", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "consultation.response_too_large", @@ -423,8 +423,8 @@ "title": "Requested record was not resolved", "description": "the requested record was not resolved", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "consultation.unresolved", @@ -442,8 +442,8 @@ "title": "Filter value is invalid", "description": "filter value is invalid", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "filter.invalid_value", @@ -461,8 +461,8 @@ "title": "Filter is not declared", "description": "filter is not declared for this operation", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "filter.unknown_field", @@ -480,8 +480,8 @@ "title": "Requested format is not supported", "description": "the requested format is not supported", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "format.unsupported", @@ -499,8 +499,8 @@ "title": "Request body is too large", "description": "request body exceeds the configured limit", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "internal.payload_too_large", @@ -518,8 +518,8 @@ "title": "Request timed out", "description": "request exceeded the configured timeout", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "internal.timeout", @@ -537,8 +537,8 @@ "title": "Request could not be served", "description": "the request could not be served", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "internal.unhandled", @@ -556,8 +556,8 @@ "title": "Request URI is too long", "description": "request URI exceeds the configured limit", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "internal.uri_too_long", @@ -575,8 +575,8 @@ "title": "Cursor is invalid", "description": "cursor is invalid for this query", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "query.cursor_invalid", @@ -594,8 +594,8 @@ "title": "Access profile selection is invalid", "description": "access profile selection is invalid", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "request.access_profile_invalid", @@ -613,8 +613,8 @@ "title": "Field selection is invalid", "description": "field selection is invalid", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "request.fields_invalid", @@ -632,8 +632,8 @@ "title": "Request media type is not supported", "description": "request body must use application/json", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "request.media_type_unsupported", @@ -651,8 +651,8 @@ "title": "Requested resource was not found", "description": "the requested resource was not found", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "resource.not_found", @@ -670,8 +670,8 @@ "title": "Service is not ready", "description": "the service is not ready", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "service.not_ready", @@ -689,8 +689,8 @@ "title": "Authoritative source is unavailable", "description": "the authoritative source is unavailable", "source": { - "path": "crates/registry-relay-v2/src/problem.rs", - "sha256": "6760c752a126f396245770c6a0e6f586dd23037e19bb28c2c9a3f1fa398a8600" + "path": "crates/registry-relay-http-contract/src/lib.rs", + "sha256": "9ea2dfbcd06be6f5c81bd2a09811f420df9120ee9133f03f95b07a65f99483d4" }, "problem": { "code": "source.unavailable", @@ -727,7 +727,7 @@ "description": "Relay V2 generated public JSON Schema.", "source": { "path": "crates/registry-relay-v2/src/artifacts.rs", - "sha256": "d157881623e63c7d02062e93e2e9709e597d292becde66938be364a9c3197b64" + "sha256": "c5e67fc35ba9ef7e6b5eccfca9b66149ffa7fd2c37e95648fffb929b3e0e9d72" }, "artifact": { "path": "products/identifiers/generated/artifacts/registry-relay/audit-event/v2alpha1.json", diff --git a/products/relay-v2/DEFINITION-OF-DONE.md b/products/relay-v2/DEFINITION-OF-DONE.md index a6e864acc..ce88cdc9e 100644 --- a/products/relay-v2/DEFINITION-OF-DONE.md +++ b/products/relay-v2/DEFINITION-OF-DONE.md @@ -58,6 +58,7 @@ fifth deployment project. | Closed operation model | Resources compile only declared list, identifier-read, named exact-lookup, and named Point-bbox search operations. Lists and searches remain independent operations. A Point search requires exactly one finite, ordered, non-wrapping CRS84 `bbox`, enforces compiled span limits, and owns its access profiles, pagination, and fixed order. Collection filters are direct publisher-defined camelCase query parameters, typed, non-personal, and exact-equality only. Transformed properties cannot be filters or fixed-order keys. `pageSize`, `cursor`, `fields`, `accessProfile`, `formatProfile`, and `bbox` are reserved. Callers cannot add SQL, source columns, joins, expressions, operators, paths, projection expressions, sort orders, or page traversal. | | Access profile selection and requester minimization | Every operation has a finite ordered `accessProfiles` map and exactly one explicit `defaultAccessProfile`. If any access profile is public, the default must be public so omission is truthful in public OpenAPI and anonymous clients never select a hidden protected default. The direct `accessProfile` parameter accepts exactly one non-empty compiled identifier; absence selects the default. Relay authenticates a supplied bearer before selection and authorizes only the selected access profile. Malformed, repeated, empty, or retired `representation` selection is `400 request.access_profile_invalid`; `representation` is never an alias. A syntactically valid unknown name, an anonymous explicit request for a protected name, and a valid principal without the selected access profile scope receive the same concealed `404 resource.not_found` outcome; purpose or row-binding denial after scope selection is `403 consultation.denied`. No request falls back to another access profile or reaches source access after refusal. `fields` may select only a non-empty duplicate-free subset of that selected profile. Registry Core remains present. Unknown, internal, source-column, cross-profile, or malformed field selections fail before source access and cannot change predicates, bindings, transforms, validation, authorization, effective handling, audit, quota, metadata, or cache posture. | | HTTP and pagination | Business routes are under `/v2`; `/health`, `/ready`, and `/openapi.json` are unversioned. `GET /v2` publishes safe first-class Registry service metadata and visible derived capabilities. Lists accept bounded `pageSize` and a client-opaque, authenticated-encrypted `cursor` and return `{items, pageInfo: {nextCursor}, meta}` with nullable `nextCursor`. Cursor confidentiality prevents filters and keyset-order values from bypassing field minimization; integrity binds revisions, operation, selected access profile and disclosure profile, filters, fixed order, field set, authorization context, and expiry; each page is reauthorized. Single reads and resolved lookups return `{data, meta}`. No caller sorting exists. | +| Relying-party client boundary | `registry-relay-http-contract` owns the fixed route and Problem Details inventory used by `registry-relay-client` and its Node and Python bindings. The client is a consumer only: it must not generate deployment OpenAPI, dynamically derive or invent routes, paginate or retry automatically, or add server behavior. Its offline contract checks and source-neutrality scan run with the Relay product gates. | | Query and serialization minimization | Relay validates the complete fixed reviewed Record before disclosure. Unrequested fields and Point carrier columns are never serialized. Invalid required values, transforms, or coordinates release nothing and return value-free `503 source.unavailable`. Ordinary JSON and JSON-LD disclose the same Registry Core identity and selected domain values. A selected `primaryGeometry` property may also serialize as RFC 7946 GeoJSON or bounded JSON-FG under `application/geo+json`; Feature `properties` plus `geometry` preserve the same disclosure, and an omitted geometry becomes `null`. `formatProfile` selects serialization only and never access. Cacheable responses require a public selected access profile, public processing handling, a snapshot, and an absent or null `pageInfo.nextCursor`; their strong ETag binds exact bytes and format. Other responses are `no-store`. | | Semantic contract | Every resource and property has a stable local semantic identity, datatype, cardinality, label, and description. `relayctl` can generate reviewed starter semantics, JSON-LD context, permitted-access-profile JSON Schema and SHACL, full-record validation schema and SHACL, and codelist scaffolding without requiring prior semantic-web expertise. The access-profile artifacts require Registry Core and validate selectable domain properties only when present; full-record artifacts retain source requiredness. `semanticModelReference` resolves to the generated vocabulary/model and the context is linked separately. Generated suggestions are visibly non-authoritative until accepted. | | External semantic alignment | Optional mappings to SEMIC, PublicSchema, schema.org, or another profile are curated, relation-qualified, versioned, and digest-pinned. Relay fetches no vocabulary and performs no inference at request time. Mapping changes appear in change-impact reports and cannot silently widen disclosure. | @@ -236,6 +237,8 @@ Before the product can be called complete, the repository must contain and CI mu - reproducible generators and drift checks for public and semantic artifacts; - generated Consultation and statistical-dataflow capabilities plus a maintained Digital Registries, API Design Guide, and SDMX profile alignment note; - source-product-neutrality and protected-value canary scans; +- an independently tested fixed Relay client route and problem inventory, plus + offline native-package construction smokes for its Node and Python bindings; - focused runtime, `relayctl`, editor-integration, shared-SQLite, issuer, audit, and serialization tests; - the applicable package and workspace formatting, check, Clippy, test, and dependency-policy gates; - one local end-to-end journey for each coequal registry using synthetic SQLite data and no external credentials. diff --git a/products/relay-v2/IMPLEMENTATION.md b/products/relay-v2/IMPLEMENTATION.md index dd3fb4a65..c39941f79 100644 --- a/products/relay-v2/IMPLEMENTATION.md +++ b/products/relay-v2/IMPLEMENTATION.md @@ -692,6 +692,8 @@ deployment TLS remains mandatory. audit event schema, and expected generated-artifact inventory. - Add source-neutrality, protected-value canary, and artifact reproducibility scripts before runtime behavior grows. +- Keep the client-facing fixed route and problem inventory independent of the + runtime implementation and gate it with the same source-neutrality policy. Gate: schemas and examples parse; every security row has an owner, enforcement point, and planned test ID; no Digital Registries OpenAPI is used. @@ -834,6 +836,20 @@ gate-inventory checks. The owning future release train runs release validation, source-model, reproducibility, SBOM, and provenance checks when it publishes the artifacts. +### 8. Relying-party client packages + +- Add `registry-relay-client` with a small fixed HTTP-contract crate and thin + Node and Python bindings. Keep the client outside the Relay runtime and + governed deployment model. +- Test the route and problem inventory offline, scan every client source for + acceptance-domain leakage, and build each native package through the existing + release-candidate client-package machinery. +- Begin Relay Node and Python package inventory only with v0.19.1. Historical + v0.19.0 release manifests remain immutable and do not claim these assets. + +Gate: the client contract, source-neutrality, native binding, and release +candidate inventory checks pass without a live Relay deployment. + ## Verification policy Run the smallest relevant package tests while iterating. Group broader and diff --git a/products/relay-v2/README.md b/products/relay-v2/README.md index 9a04d697e..ac68814e1 100644 --- a/products/relay-v2/README.md +++ b/products/relay-v2/README.md @@ -16,6 +16,9 @@ The initial boundary is intentionally narrow: Aggregate Data statistical-dataflow pattern and the aligned SDMX read subset; - responses are unsigned; - Registry Mint is optional and Registry Evidence remains a separate product; +- the separately versioned Relay client, including its Node and Python native + bindings, consumes the fixed public HTTP contract but never adds a route, + deployment capability, or Relay authorization semantic; - the written GovStack drafts are alignment inputs, not conformance contracts; - the obsolete Digital Registries OpenAPI is not consumed. @@ -55,3 +58,7 @@ used only through the explicit temporary-fetch option or an external cache in Set `RELAY_V2_SDMX_CONFORMANCE=1` when running `scripts/test-http.sh` to fetch the digest-locked schemas temporarily and validate generated data and structure responses. + +`scripts/check-client-contract.sh` verifies the standalone fixed route and +problem inventory used by `registry-relay-client`; it has no live deployment or +fixture dependency. diff --git a/products/relay-v2/contracts/security-invariant-matrix.yaml b/products/relay-v2/contracts/security-invariant-matrix.yaml index 7c8352206..0ec81c3a1 100644 --- a/products/relay-v2/contracts/security-invariant-matrix.yaml +++ b/products/relay-v2/contracts/security-invariant-matrix.yaml @@ -308,6 +308,37 @@ invariants: - {path: crates/registry-relay-v2/src/problem.rs, name: version_zero_traceparent_rejects_non_lowercase_hex} - {path: crates/registry-relay-v2/src/problem.rs, name: invalid_traceparent_is_replaced_with_server_context} - {path: crates/registry-relay-v2/src/problem.rs, name: caller_tracestate_is_never_echoed_in_ordinary_or_problem_headers} + - id: sec-client-credential-transport + threat: A relying-party client sends credentials to an unsafe destination, follows an attacker-directed redirect, uses an ambient proxy or retry path, or renders a secret in diagnostics. + enforcementPoint: Closed outbound-client construction, strict service-base validation, protected credential transport, and value-free token types. + expected: Client URLs preserve only an explicit deployment prefix; bearer acquisition is never attempted for probes or public OpenAPI, and unsafe base URL components cannot enter diagnostic output. + evidence: relay-client-credential-transport-tests + negativeTest: probes_and_openapi_never_acquire_a_token + tests: + - {path: crates/registry-platform-httputil/src/client/mod.rs, name: service_base_urls_preserve_prefixes_and_refuse_credential_leaks} + - {path: crates/registry-relay-client/tests/http_boundary.rs, name: base_prefix_is_preserved_for_every_route_family} + - {path: crates/registry-relay-client/tests/http_boundary.rs, name: probes_and_openapi_never_acquire_a_token} + - id: sec-client-response-contract + threat: A malformed, oversized, replay-confusing, or forged response is interpreted as a Relay success or a typed refusal, weakening concealment or caller handling. + enforcementPoint: Bounded response parsing with exact W3C response trace, registered Problem Details, Retry-After, and strong ETag validation. + expected: The client accepts only one canonical lower-case traceparent, an exact registered six-member problem whose trace matches the header, and an empty 304 carrying the matching strong SHA-256 ETag. + evidence: relay-client-response-contract-tests + negativeTest: problem_requires_exact_registered_six_member_body_and_trace_equality + tests: + - {path: crates/registry-relay-client/tests/http_boundary.rs, name: response_trace_requires_one_canonical_lowercase_v0_traceparent} + - {path: crates/registry-relay-client/tests/http_boundary.rs, name: problem_requires_exact_registered_six_member_body_and_trace_equality} + - {path: crates/registry-relay-client/tests/http_boundary.rs, name: not_modified_http_requires_matching_strong_sha256_etag} + - {path: crates/registry-relay-client/src/client.rs, name: not_modified_requires_matching_strong_sha256_etag_and_empty_body} + - id: sec-client-request-confinement + threat: Caller options synthesize or change Relay route or query semantics. + enforcementPoint: Fixed route templates, client-owned reserved query parameters, opaque continuation state, and exact percent encoding for SDMX key material. + expected: The client keeps fixed routes and reserved query ownership closed, cannot mix continuation state with changed first-page facts, and never decodes a literal SDMX plus as a space. + evidence: relay-client-request-confinement-tests + negativeTest: continuations_cannot_mix_first_page_facts + tests: + - {path: crates/registry-relay-client/src/query.rs, name: first_page_filters_cannot_claim_reserved_parameters} + - {path: crates/registry-relay-client/tests/http_boundary.rs, name: continuations_cannot_mix_first_page_facts} + - {path: crates/registry-relay-client/src/query.rs, name: sdmx_literal_plus_is_never_decoded_as_space} - id: sec-unsigned-family-boundary threat: Relay output is mistaken for portable signed Evidence or API authentication is mistaken for Identity Federation. enforcementPoint: Product routes, dependencies, discovery, and family-alignment inventory. diff --git a/products/relay-v2/scripts/check-client-contract.sh b/products/relay-v2/scripts/check-client-contract.sh new file mode 100755 index 000000000..e5ef73aad --- /dev/null +++ b/products/relay-v2/scripts/check-client-contract.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The Relay client intentionally owns no served route. Its independently +# versioned wire inventory is the client-facing contract, and it must stay +# testable without a running Relay process or acceptance fixture. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" +cd "$REPO_ROOT" + +cargo test --locked -p registry-relay-http-contract +cargo test --locked -p registry-relay-client + +echo "relay-v2 client contract passed" diff --git a/products/relay-v2/scripts/check-contracts.sh b/products/relay-v2/scripts/check-contracts.sh index 552cf0af0..1d08b20a2 100755 --- a/products/relay-v2/scripts/check-contracts.sh +++ b/products/relay-v2/scripts/check-contracts.sh @@ -7,6 +7,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" python3 "$SCRIPT_DIR/validate_product.py" python3 "$SCRIPT_DIR/validate-sdmx-profile.py" bash "$SCRIPT_DIR/check-source-neutrality.sh" +bash "$SCRIPT_DIR/check-client-contract.sh" python3 -m unittest \ "$SCRIPT_DIR/test_validate_product.py" \ "$SCRIPT_DIR/test_adopter_workflow_openapi.py" \ diff --git a/products/relay-v2/scripts/check-source-neutrality.sh b/products/relay-v2/scripts/check-source-neutrality.sh index 8b27042a8..8dbc4036d 100755 --- a/products/relay-v2/scripts/check-source-neutrality.sh +++ b/products/relay-v2/scripts/check-source-neutrality.sh @@ -6,13 +6,46 @@ PRODUCT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" forbidden='social[-_ ]?assistance|business[-_ ]?registry|civil[-_ ]?event|labour[-_ ]?statistics|crvs|birth|death|household|benefit|company' -if rg -i -l "$forbidden" \ - "$PRODUCT_DIR/../../crates/registry-relay-v2/src" \ - "$PRODUCT_DIR/../../crates/registry-relayctl/src" \ - "$PRODUCT_DIR/../../crates/registry-platform-sqlite/src" >/dev/null; then +production_sources=( + "$PRODUCT_DIR/../../crates/registry-relay-v2/src" + "$PRODUCT_DIR/../../crates/registry-relayctl/src" + "$PRODUCT_DIR/../../crates/registry-relay-http-contract/src" + "$PRODUCT_DIR/../../crates/registry-relay-http-contract/Cargo.toml" + "$PRODUCT_DIR/../../crates/registry-relay-client/src" + "$PRODUCT_DIR/../../crates/registry-relay-client/Cargo.toml" + "$PRODUCT_DIR/../../crates/registry-relay-client-node/src" + "$PRODUCT_DIR/../../crates/registry-relay-client-node/Cargo.toml" + "$PRODUCT_DIR/../../crates/registry-relay-client-node/client.js" + "$PRODUCT_DIR/../../crates/registry-relay-client-node/client.d.ts" + "$PRODUCT_DIR/../../crates/registry-relay-client-node/index.js" + "$PRODUCT_DIR/../../crates/registry-relay-client-node/index.d.ts" + "$PRODUCT_DIR/../../crates/registry-relay-client-node/package.json" + "$PRODUCT_DIR/../../crates/registry-relay-client-py/src" + "$PRODUCT_DIR/../../crates/registry-relay-client-py/Cargo.toml" + "$PRODUCT_DIR/../../crates/registry-relay-client-py/python" + "$PRODUCT_DIR/../../crates/registry-relay-client-py/pyproject.toml" + "$PRODUCT_DIR/../../crates/registry-platform-sqlite/src" +) + +for path in "${production_sources[@]}"; do + if [[ ! -e "$path" ]]; then + echo "relay-v2 source-neutrality: required production source is missing: $path" >&2 + exit 1 + fi +done + +set +e +rg -i -l "$forbidden" "${production_sources[@]}" >/dev/null +rg_status=$? +set -e +if [[ "$rg_status" -eq 0 ]]; then echo "relay-v2 source-neutrality: acceptance-domain term in Relay V2 production source" >&2 exit 1 fi +if [[ "$rg_status" -ne 1 ]]; then + echo "relay-v2 source-neutrality: production source scan failed" >&2 + exit "$rg_status" +fi while IFS= read -r path; do relative="${path#"$PRODUCT_DIR/"}" diff --git a/products/relay-v2/scripts/validate_product.py b/products/relay-v2/scripts/validate_product.py index aecd790bd..f9968e51b 100644 --- a/products/relay-v2/scripts/validate_product.py +++ b/products/relay-v2/scripts/validate_product.py @@ -80,6 +80,9 @@ "sec-value-free-diagnostics", "sec-value-free-operational-logs", "sec-value-free-trace-context", + "sec-client-credential-transport", + "sec-client-response-contract", + "sec-client-request-confinement", "sec-unsigned-family-boundary", } SIMPLE_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") diff --git a/release/scripts/check-gates-inventory.py b/release/scripts/check-gates-inventory.py index b51b964f9..d9e979d56 100644 --- a/release/scripts/check-gates-inventory.py +++ b/release/scripts/check-gates-inventory.py @@ -179,6 +179,15 @@ "Relay V2 coequal HTTP journeys", "run: products/relay-v2/scripts/test-http.sh", ), + ("Relay client contract gate", "relay-client-contracts:"), + ( + "Relay client contract consistency", + "run: products/relay-v2/scripts/check-client-contract.sh", + ), + ( + "Relay client source neutrality", + "run: products/relay-v2/scripts/check-source-neutrality.sh", + ), ( "Release helper tests", "run: python3 -m unittest release/scripts/test_registry_release.py", diff --git a/release/scripts/check-release-source-model.sh b/release/scripts/check-release-source-model.sh index 7cbe903d8..1a1da2125 100755 --- a/release/scripts/check-release-source-model.sh +++ b/release/scripts/check-release-source-model.sh @@ -61,6 +61,10 @@ require_path "registry-platform crates" "${stack_root}/crates/registry-platform- require_path "registry-manifest crates" "${stack_root}/crates/registry-manifest-core" require_path "registry-relay-v2 crate" "${stack_root}/crates/registry-relay-v2" require_path "registry-relayctl crate" "${stack_root}/crates/registry-relayctl" +require_path "registry-relay HTTP contract crate" "${stack_root}/crates/registry-relay-http-contract" +require_path "registry-relay client crate" "${stack_root}/crates/registry-relay-client" +require_path "registry-relay Node client binding" "${stack_root}/crates/registry-relay-client-node" +require_path "registry-relay Python client binding" "${stack_root}/crates/registry-relay-client-py" require_path "registry-evidence crate" "${stack_root}/crates/registry-evidence" require_path "registry-evidencectl crate" "${stack_root}/crates/registry-evidencectl" require_path "registry-mint crate" "${stack_root}/crates/registry-mint" diff --git a/release/scripts/registry-release b/release/scripts/registry-release index b804e29d8..03134fbd8 100755 --- a/release/scripts/registry-release +++ b/release/scripts/registry-release @@ -45,6 +45,7 @@ EVIDENCE_OID4VCI_MINIMUM_VERSION = (0, 18, 0) REGISTRYCTL_INSTALLER_MINIMUM_VERSION = (0, 14, 0) RELAY_V2_RELEASE_MINIMUM_VERSION = (0, 19, 0) RELAY_INSTALLER_MINIMUM_VERSION = (0, 19, 1) +RELAY_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 19, 1) IDENTIFIER_CATALOG_RELEASE_MINIMUM_VERSION = (0, 19, 1) IDENTIFIER_CATALOG_RELATIVE_PATH = "products/identifiers/generated/catalog.v1.json" # Historical v0.10-v0.16 releases shipped the V1 Relay and Notary identities. @@ -191,6 +192,10 @@ def artifact_inventory_errors(version: str, artifacts: dict[Any, Any]) -> list[s expected_inventory = set(RELAY_V2_ARTIFACT_INVENTORY) if parsed_version >= RELAY_INSTALLER_MINIMUM_VERSION: expected_inventory.add("relay-installer") + if parsed_version >= RELAY_CLIENT_PACKAGE_MINIMUM_VERSION: + expected_inventory.update( + {"relay-client-node", "relay-client-python"} + ) else: expected_inventory = set( POST_NOTARY_ARTIFACT_INVENTORY diff --git a/release/scripts/release_candidate.py b/release/scripts/release_candidate.py index 4aa389900..57725369c 100644 --- a/release/scripts/release_candidate.py +++ b/release/scripts/release_candidate.py @@ -52,6 +52,7 @@ CANDIDATE_V2_MINIMUM_VERSION = (0, 16, 0) RELAY_V2_RELEASE_MINIMUM_VERSION = (0, 19, 0) RELAY_INSTALLER_MINIMUM_VERSION = (0, 19, 1) +RELAY_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 19, 1) RELAY_V2_IMAGE_NAMES = {"relay"} ATTEMPT_ARTIFACT_PREFIXES = { "registry-stack-candidate-build-a", @@ -196,6 +197,16 @@ def _relay_v2_payload_inventory(version: str) -> dict[str, str]: ): inventory[f"relay-{tag}-install.sh"] = "installer" inventory["relay-install.sh"] = "installer" + if ( + tuple(int(part) for part in version.split(".")) + >= RELAY_CLIENT_PACKAGE_MINIMUM_VERSION + ): + for platform in ("linux-amd64-glibc", "linux-arm64-glibc", "macos-arm64"): + inventory[f"relay-client-node-{tag}-{platform}.tgz"] = "client-package" + for platform in ("linux_x86_64", "linux_aarch64", "macosx_11_0_arm64"): + inventory[ + f"registry_relay_client-{version}-cp310-abi3-{platform}.whl" + ] = "client-package" return inventory diff --git a/release/scripts/smoke-relay-client-package.js b/release/scripts/smoke-relay-client-package.js new file mode 100755 index 000000000..0aedd4de2 --- /dev/null +++ b/release/scripts/smoke-relay-client-package.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('node:assert'); +const { RelayClient } = require('@registrystack/relay-client'); + +// The reserved host and placeholder bearer make a network regression fail +// closed if a future constructor accidentally performs I/O. +assert.strictEqual(typeof RelayClient, 'function'); +const client = new RelayClient({ + baseUrl: 'https://relay.invalid', + authorization: { static: 'placeholder-token' }, +}); +assert.ok(client); + +console.log('Node Relay client package smoke passed'); diff --git a/release/scripts/smoke-relay-client-package.py b/release/scripts/smoke-relay-client-package.py new file mode 100755 index 000000000..8c9d94d07 --- /dev/null +++ b/release/scripts/smoke-relay-client-package.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""Offline construction smoke for an installed Registry Relay Python wheel.""" + +import registry_relay_client as client_module + + +def main() -> None: + # The reserved host and placeholder bearer make a network regression fail + # closed if a future constructor accidentally performs I/O. + client = client_module.RelayClient( + base_url="https://relay.invalid", + authorization="placeholder-token", + ) + if client is None: + raise SystemExit("Relay client construction returned no client") + if not callable(client_module.RelayClient): + raise SystemExit("RelayClient is not an exported constructor") + + print("Python Relay client package smoke passed") + + +if __name__ == "__main__": + main() diff --git a/release/scripts/test_check_gates_inventory.py b/release/scripts/test_check_gates_inventory.py index 595e053fd..fc47d4c9c 100644 --- a/release/scripts/test_check_gates_inventory.py +++ b/release/scripts/test_check_gates_inventory.py @@ -512,6 +512,28 @@ def test_missing_relay_v2_product_gates_are_reported(self) -> None: text = self.workflow.replace(snippet, replacement) self.assertIn(gate, self.module.missing_gates(text)) + def test_missing_relay_client_contract_gates_are_reported(self) -> None: + for snippet, replacement, gate in ( + ( + "relay-client-contracts:", + "relay-client-disabled:", + "Relay client contract gate", + ), + ( + "run: products/relay-v2/scripts/check-client-contract.sh", + "run: true # Relay client contracts disabled", + "Relay client contract consistency", + ), + ( + "run: products/relay-v2/scripts/check-source-neutrality.sh", + "run: true # Relay client neutrality disabled", + "Relay client source neutrality", + ), + ): + with self.subTest(gate=gate): + text = self.workflow.replace(snippet, replacement) + self.assertIn(gate, self.module.missing_gates(text)) + def test_missing_debian13_image_contract_is_reported(self) -> None: text = self.workflow.replace( "run: python3 release/scripts/check-debian13-images.py", diff --git a/release/scripts/test_check_release_source_model.py b/release/scripts/test_check_release_source_model.py index c7c4d3262..674ccf7d2 100644 --- a/release/scripts/test_check_release_source_model.py +++ b/release/scripts/test_check_release_source_model.py @@ -273,6 +273,10 @@ def __enter__(self) -> Path: "crates/registry-relay", "crates/registry-relay-v2", "crates/registry-relayctl", + "crates/registry-relay-http-contract", + "crates/registry-relay-client", + "crates/registry-relay-client-node", + "crates/registry-relay-client-py", "crates/registry-evidence", "crates/registry-evidencectl", "crates/registry-mint", diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 5480ca86b..ec9cca574 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -466,6 +466,7 @@ def test_required_rust_context_aggregates_path_gated_shards(self) -> None: "evidence-contracts", "identifiers", "relay-contracts", + "relay-client-contracts", "relay-v2-contracts", }, set(rust_result["needs"]), @@ -2549,6 +2550,9 @@ def test_relay_installer_joins_the_exact_inventory_after_v0_19_0(self) -> None: root = Path(tmp) historical = write_manifest(root, version="0.19.0") historical_result = run_tool("validate", str(historical)) + historical_data = yaml.safe_load( + historical.read_text(encoding="utf-8") + ) current = write_manifest( root, @@ -2557,6 +2561,9 @@ def test_relay_installer_joins_the_exact_inventory_after_v0_19_0(self) -> None: ) current_result = run_tool("validate", str(current)) data = yaml.safe_load(current.read_text(encoding="utf-8")) + self.assertNotIn("relay-client-node", historical_data["artifacts"]) + self.assertEqual("0.19.1", data["artifacts"]["relay-client-node"]) + self.assertEqual("0.19.1", data["artifacts"]["relay-client-python"]) del data["artifacts"]["relay-installer"] current.write_text( yaml.safe_dump(data, sort_keys=False), encoding="utf-8" @@ -4362,6 +4369,8 @@ def write_manifest( } if version_tuple >= (0, 19, 1): artifacts["relay-installer"] = version + artifacts["relay-client-node"] = version + artifacts["relay-client-python"] = version if include_registryctl_image_lock is None: include_registryctl_image_lock = (0, 9, 0) <= version_tuple < (0, 19, 0) if include_registryctl_image_lock: @@ -4412,11 +4421,28 @@ def write_manifest( }, } if version_tuple >= (0, 19, 1): - catalog_path = ROOT / "products/identifiers/generated/catalog.v1.json" - catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + catalog_relative_path = "products/identifiers/generated/catalog.v1.json" + repository = directory + repository_result = subprocess.run( + ["git", "-C", str(directory), "rev-parse", "--show-toplevel"], + check=False, + capture_output=True, + text=True, + ) + if repository_result.returncode == 0: + repository = Path(repository_result.stdout.strip()) + else: + repository = ROOT + catalog_result = subprocess.run( + ["git", "-C", str(repository), "show", f"{source_ref}:{catalog_relative_path}"], + check=True, + capture_output=True, + ) + catalog_bytes = catalog_result.stdout + catalog = json.loads(catalog_bytes) manifest["identifier_catalog"] = { - "path": "products/identifiers/generated/catalog.v1.json", - "sha256": hashlib.sha256(catalog_path.read_bytes()).hexdigest(), + "path": catalog_relative_path, + "sha256": hashlib.sha256(catalog_bytes).hexdigest(), "entry_count": len(catalog["entries"]), } path = directory / "release-manifest.yaml" diff --git a/release/scripts/test_registry_release_plans.py b/release/scripts/test_registry_release_plans.py index 0d63d723f..4216fd0f1 100644 --- a/release/scripts/test_registry_release_plans.py +++ b/release/scripts/test_registry_release_plans.py @@ -50,6 +50,7 @@ "relay-installer", "relayctl", ) +RELAY_CLIENT_PACKAGE_MINIMUM_VERSION = (0, 19, 1) def run(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: @@ -95,6 +96,8 @@ def manifest(version: str, release_id: str, source_ref: str, status: str) -> dic if version_tuple >= (0, 19, 0) else LEGACY_ARTIFACT_INVENTORY ) + if version_tuple >= RELAY_CLIENT_PACKAGE_MINIMUM_VERSION: + inventory += ("relay-client-node", "relay-client-python") data = { "stack": { "release": release_id, diff --git a/release/scripts/test_release_candidate.py b/release/scripts/test_release_candidate.py index 713d67aea..a3f4029cf 100644 --- a/release/scripts/test_release_candidate.py +++ b/release/scripts/test_release_candidate.py @@ -1289,6 +1289,26 @@ def test_relay_installer_payloads_begin_after_v0_19_0(self) -> None: self.assertEqual("installer", current["relay-v0.19.1-install.sh"]) self.assertEqual("installer", current["relay-install.sh"]) + def test_relay_client_payloads_begin_after_v0_19_0(self) -> None: + historical = self.module._relay_v2_payload_inventory("0.19.0") + current = self.module._relay_v2_payload_inventory("0.19.1") + + self.assertNotIn( + "relay-client-node-v0.19.0-linux-amd64-glibc.tgz", historical + ) + self.assertNotIn( + "registry_relay_client-0.19.0-cp310-abi3-linux_x86_64.whl", + historical, + ) + self.assertEqual( + "client-package", + current["relay-client-node-v0.19.1-linux-amd64-glibc.tgz"], + ) + self.assertEqual( + "client-package", + current["registry_relay_client-0.19.1-cp310-abi3-linux_x86_64.whl"], + ) + def test_v2_security_evidence_members_follow_candidate_images(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index e2d78a173..8a032b7d8 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -445,7 +445,7 @@ def test_next_release_embeds_and_smokes_relay_installer_aliases(self) -> None: self.assertIn('"${relay_install_dir}/relay" --version', assemble) self.assertIn("relay_patch >= 1", assemble) - def test_builds_and_smokes_stable_evidence_client_packages(self) -> None: + def test_builds_and_smokes_stable_native_client_packages(self) -> None: text, document = workflow("release-candidate.yml") clients = document["jobs"]["clients"] matrix = clients["strategy"]["matrix"]["include"] @@ -472,16 +472,26 @@ def test_builds_and_smokes_stable_evidence_client_packages(self) -> None: clients["env"]["CLIENT_VERSION"], "${{ needs.validate.outputs.version }}", ) - wheel = step_run(document, "clients", "Build the Python client wheel") + wheel = step_run(document, "clients", "Build Python client wheels") self.assertIn("--compatibility linux", wheel) - self.assertIn("expected exactly one wheel", wheel) + self.assertIn("registry_${client}_client", wheel) + self.assertIn("expected_wheels=2", wheel) self.assertIn("--require-hashes --only-binary=:all:", wheel) self.assertIn("release/requirements/maturin-1.9.6.txt", wheel) - node = step_run(document, "clients", "Build the Node client package") + node = step_run(document, "clients", "Build Node client packages") self.assertIn( - "package/evidence-client.${{ matrix.napi_platform }}.node", + "package/${client}-client.${{ matrix.napi_platform }}.node", node, ) + self.assertIn("registry-${client}-client-node", node) + for name in ("Smoke Python client wheels", "Smoke Node client packages"): + smoke = step_run(document, "clients", name) + self.assertIn("smoke-${client}-client-package", smoke) + self.assertIn("for client in evidence relay", smoke) + self.assertIn( + "crates/registry-relay-client-node/package-lock.json", + str(clients), + ) assemble = step_run( document, "assemble", @@ -492,6 +502,10 @@ def test_builds_and_smokes_stable_evidence_client_packages(self) -> None: self.assertIn("expected-client-assets", assemble) self.assertIn("actual-client-assets", assemble) self.assertIn("diff -u", assemble) + self.assertIn("include_relay_clients=1", assemble) + self.assertIn("expected_client_assets=4", assemble) + self.assertIn("relay-client-node-", assemble) + self.assertIn("registry_relay_client-", assemble) self.assertIn("kind=client-package", text) for forbidden in ("npm publish", "maturin publish", "twine upload"): self.assertNotIn(forbidden, text) From d5cac23cc521b22603c8daae461f280df3461c3a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 20:17:19 +0700 Subject: [PATCH 2/9] fix(ci): make Relay neutrality scan portable Signed-off-by: Jeremi Joslin --- .../scripts/check-source-neutrality.sh | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/products/relay-v2/scripts/check-source-neutrality.sh b/products/relay-v2/scripts/check-source-neutrality.sh index 8dbc4036d..7a62700f8 100755 --- a/products/relay-v2/scripts/check-source-neutrality.sh +++ b/products/relay-v2/scripts/check-source-neutrality.sh @@ -34,20 +34,32 @@ for path in "${production_sources[@]}"; do fi done +# The hosted contract runner does not install ripgrep. Keep each grep status +# explicit so a missing path or unreadable source fails the gate closed. set +e -rg -i -l "$forbidden" "${production_sources[@]}" >/dev/null -rg_status=$? +grep -EilR -- "$forbidden" "${production_sources[@]}" >/dev/null +grep_status=$? set -e -if [[ "$rg_status" -eq 0 ]]; then +if [[ "$grep_status" -eq 0 ]]; then echo "relay-v2 source-neutrality: acceptance-domain term in Relay V2 production source" >&2 exit 1 fi -if [[ "$rg_status" -ne 1 ]]; then +if [[ "$grep_status" -ne 1 ]]; then echo "relay-v2 source-neutrality: production source scan failed" >&2 - exit "$rg_status" + exit "$grep_status" +fi + +set +e +domain_paths=$(grep -EilR -- "$forbidden" "$PRODUCT_DIR") +grep_status=$? +set -e +if [[ "$grep_status" -gt 1 ]]; then + echo "relay-v2 source-neutrality: product source scan failed" >&2 + exit "$grep_status" fi while IFS= read -r path; do + [[ -n "$path" ]] || continue relative="${path#"$PRODUCT_DIR/"}" case "$relative" in acceptance/*|CONCEPT.md|DEFINITION-OF-DONE.md|CONFIGURATION-EXAMPLES.md|IMPLEMENTATION.md|README.md|STANDARDS-ALIGNMENT.md|scripts/check-source-neutrality.sh|scripts/check-generated.sh|scripts/test_adopter_workflow.py|scripts/validate_product.py|scripts/test_validate_product.py|contracts/generated-baselines.yaml|contracts/package-layout.yaml|contracts/acceptance-scenario-matrix.yaml|contracts/security-invariant-matrix.yaml) @@ -56,11 +68,19 @@ while IFS= read -r path; do esac echo "relay-v2 source-neutrality: domain term outside acceptance/docs: $relative" >&2 exit 1 -done < <(rg -i -l "$forbidden" "$PRODUCT_DIR" || true) +done <<<"$domain_paths" -if rg -i -n 'legacy/generated-crud|api/legacy|test/openAPI' "$PRODUCT_DIR/acceptance" "$PRODUCT_DIR/contracts" >/dev/null; then +set +e +grep -EinR -- 'legacy/generated-crud|api/legacy|test/openAPI' "$PRODUCT_DIR/acceptance" "$PRODUCT_DIR/contracts" >/dev/null +grep_status=$? +set -e +if [[ "$grep_status" -eq 0 ]]; then echo "relay-v2 source-neutrality: legacy Digital Registries OpenAPI input referenced" >&2 exit 1 fi +if [[ "$grep_status" -ne 1 ]]; then + echo "relay-v2 source-neutrality: legacy input scan failed" >&2 + exit "$grep_status" +fi echo "relay-v2 source-neutrality passed" From d4f513d176064e8ed148bc065107ec129d188a34 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 20:19:26 +0700 Subject: [PATCH 3/9] fix(relay-client): address binding analysis findings Signed-off-by: Jeremi Joslin --- crates/registry-relay-client-py/tests/python/bootstrap.py | 7 ++----- .../registry-relay-client-py/tests/python/test_errors.py | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/registry-relay-client-py/tests/python/bootstrap.py b/crates/registry-relay-client-py/tests/python/bootstrap.py index 36711d4dd..3c4ce8b71 100644 --- a/crates/registry-relay-client-py/tests/python/bootstrap.py +++ b/crates/registry-relay-client-py/tests/python/bootstrap.py @@ -2,6 +2,7 @@ from __future__ import annotations +import functools import pathlib import platform import shutil @@ -13,7 +14,6 @@ _MODULE_NAME = "registry_relay_client" _TARGET_DEBUG = _WORKSPACE_ROOT / "target" / "debug" _IMPORT_DIR = _TARGET_DEBUG / "relay_python_module" -_built = False def _library() -> pathlib.Path: @@ -23,10 +23,8 @@ def _library() -> pathlib.Path: return _TARGET_DEBUG / f"lib{_MODULE_NAME}{suffix}" +@functools.cache def ensure_built() -> None: - global _built - if _built: - return subprocess.run( [ "cargo", @@ -48,4 +46,3 @@ def ensure_built() -> None: shutil.copyfile(source, _IMPORT_DIR / f"{_MODULE_NAME}.so") if str(_IMPORT_DIR) not in sys.path: sys.path.insert(0, str(_IMPORT_DIR)) - _built = True diff --git a/crates/registry-relay-client-py/tests/python/test_errors.py b/crates/registry-relay-client-py/tests/python/test_errors.py index 9ec112b08..3fba46d79 100644 --- a/crates/registry-relay-client-py/tests/python/test_errors.py +++ b/crates/registry-relay-client-py/tests/python/test_errors.py @@ -8,7 +8,7 @@ TESTS = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(TESTS)) import bootstrap # noqa: E402 -from relay_server import RelayServer, Request, Response, TRACE_ID # noqa: E402 +from relay_server import RelayServer, Response, TRACE_ID # noqa: E402 bootstrap.ensure_built() import registry_relay_client as relay # noqa: E402 From 4e0482f668089ec1150cd63aef627f30ae13addd Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 20:24:10 +0700 Subject: [PATCH 4/9] test(docs): follow shared Relay route constants Signed-off-by: Jeremi Joslin --- docs/site/scripts/ops-posture-spec.test.mjs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/site/scripts/ops-posture-spec.test.mjs b/docs/site/scripts/ops-posture-spec.test.mjs index d264e131e..c1561286d 100644 --- a/docs/site/scripts/ops-posture-spec.test.mjs +++ b/docs/site/scripts/ops-posture-spec.test.mjs @@ -10,9 +10,14 @@ const siteRoot = resolve(here, '..'); const repositoryRoot = resolve(siteRoot, '../..'); const specPath = resolve(siteRoot, 'src/content/docs/spec/rs-op-posture.mdx'); const relayCrate = resolve(repositoryRoot, 'crates/registry-relay-v2/src'); +const relayHttpContract = resolve( + repositoryRoot, + 'crates/registry-relay-http-contract/src/lib.rs', +); const page = readFileSync(specPath, 'utf8'); const serverSource = readFileSync(resolve(relayCrate, 'server.rs'), 'utf8'); +const httpContractSource = readFileSync(relayHttpContract, 'utf8'); const mainSource = readFileSync(resolve(relayCrate, 'main.rs'), 'utf8'); const contractSource = readFileSync(resolve(relayCrate, 'contract.rs'), 'utf8'); const startupSource = readFileSync(resolve(relayCrate, 'startup.rs'), 'utf8'); @@ -52,8 +57,10 @@ test('RS-OP-POSTURE retires the admin posture requirements without reusing ident }); test('RS-OP-POSTURE states the operational probe inventory the runtime serves', () => { - assert.match(serverSource, /\.route\("\/health", get\(crate::api::health\)\)/); - assert.match(serverSource, /\.route\("\/ready", get\(crate::api::ready\)\)/); + assert.match(httpContractSource, /pub const HEALTH: &str = "\/health";/); + assert.match(httpContractSource, /pub const READY: &str = "\/ready";/); + assert.match(serverSource, /\.route\(routes::HEALTH, get\(crate::api::health\)\)/); + assert.match(serverSource, /\.route\(routes::READY, get\(crate::api::ready\)\)/); assert.doesNotMatch(serverSource, /\.route\("\/admin/); assert.doesNotMatch(serverSource, /\.route\("\/metrics/); From 1fa8f7b511de6b1fbc2a2e663270420b90376310 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 20:41:14 +0700 Subject: [PATCH 5/9] fix(relay-client): align binding input types Signed-off-by: Jeremi Joslin --- .../__test__/binding.test.js | 2 +- .../__test__/drift.test.js | 10 ++++++++++ crates/registry-relay-client-node/client.d.ts | 2 +- .../python/registry_relay_client/__init__.pyi | 12 ++++++------ .../tests/python/test_drift.py | 6 ++++++ 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/registry-relay-client-node/__test__/binding.test.js b/crates/registry-relay-client-node/__test__/binding.test.js index b44251661..dbb2ab713 100644 --- a/crates/registry-relay-client-node/__test__/binding.test.js +++ b/crates/registry-relay-client-node/__test__/binding.test.js @@ -105,7 +105,7 @@ test('every endpoint accepts its documented plain input graph', async () => { await expectNotModified(client.continueListRecords({ route: { kind: 'records', resource: 'people' }, cursor: 'records-cursor', - format: 'json', + format: 'geojson-rfc7946', accessProfile: 'public', }, ETAG)); await expectNotModified(client.readRecord('people', 'person-1', { diff --git a/crates/registry-relay-client-node/__test__/drift.test.js b/crates/registry-relay-client-node/__test__/drift.test.js index 12e9abff3..1a5e04014 100644 --- a/crates/registry-relay-client-node/__test__/drift.test.js +++ b/crates/registry-relay-client-node/__test__/drift.test.js @@ -42,6 +42,16 @@ test('the handwritten facade declares every method', () => { } }); +test('continuation format literals match the core wire projection', () => { + const declaration = fs.readFileSync(path.join(__dirname, '..', 'client.d.ts'), 'utf8'); + const continuation = declaration.match( + /export interface CollectionContinuation[\s\S]*?\n}/, + ); + assert.ok(continuation); + assert.match(continuation[0], /'geojson-rfc7946'/); + assert.doesNotMatch(continuation[0], /'geo-json-rfc7946'/); +}); + test('only the normalized package entry point is exported', () => { assert.equal(require('@registrystack/relay-client').RelayClient, wrapper.RelayClient); assert.throws( diff --git a/crates/registry-relay-client-node/client.d.ts b/crates/registry-relay-client-node/client.d.ts index 780f9f376..c39688c16 100644 --- a/crates/registry-relay-client-node/client.d.ts +++ b/crates/registry-relay-client-node/client.d.ts @@ -75,7 +75,7 @@ export interface SearchRoute { export interface CollectionContinuation { route: Route cursor: string - format: 'json' | 'json-ld' | 'geo-json-rfc7946' | 'json-fg' + format: 'json' | 'json-ld' | 'geojson-rfc7946' | 'json-fg' accessProfile?: string } diff --git a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi index 4e8302cb5..c74246212 100644 --- a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi +++ b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi @@ -1,6 +1,6 @@ """Types for the synchronous Registry Relay V2 client binding.""" -from typing import Any, Literal, Mapping, Optional, Sequence, TypedDict, Union +from typing import Any, Literal, Optional, Sequence, TypedDict, Union RecordFormat = Literal["json", "json-ld", "geojson", "json-fg"] SdmxDataFormat = Literal["json", "csv"] @@ -10,7 +10,7 @@ Selector = Union[str, int, bool] class _PrivateKeyJwtRequired(TypedDict): token_endpoint: str client_id: str - client_key: Mapping[str, Any] + client_key: dict[str, Any] class PrivateKeyJwtConfig(_PrivateKeyJwtRequired, total=False): @@ -152,7 +152,7 @@ class RelayClient: fields: Optional[Sequence[str]] = ..., access_profile: Optional[str] = ..., format: RecordFormat = ..., - filters: Optional[Mapping[str, str]] = ..., + filters: Optional[dict[str, str]] = ..., bbox: Optional[Sequence[float]] = ..., etag: Optional[str] = ..., ) -> CollectionPageOutcome: ... @@ -175,7 +175,7 @@ class RelayClient: self, resource: str, lookup: str, - selectors: Mapping[str, Selector], + selectors: dict[str, Selector], *, fields: Optional[Sequence[str]] = ..., access_profile: Optional[str] = ..., @@ -191,7 +191,7 @@ class RelayClient: fields: Optional[Sequence[str]] = ..., access_profile: Optional[str] = ..., format: RecordFormat = ..., - filters: Optional[Mapping[str, str]] = ..., + filters: Optional[dict[str, str]] = ..., bbox: Optional[Sequence[float]] = ..., etag: Optional[str] = ..., ) -> CollectionPageOutcome: ... @@ -210,7 +210,7 @@ class RelayClient: version: str, *, key: Optional[str] = ..., - constraints: Optional[Mapping[str, str]] = ..., + constraints: Optional[dict[str, str]] = ..., offset: Optional[int] = ..., limit: Optional[int] = ..., dimension_at_observation: Optional[str] = ..., diff --git a/crates/registry-relay-client-py/tests/python/test_drift.py b/crates/registry-relay-client-py/tests/python/test_drift.py index 33484e85d..2d2c37b69 100644 --- a/crates/registry-relay-client-py/tests/python/test_drift.py +++ b/crates/registry-relay-client-py/tests/python/test_drift.py @@ -119,6 +119,12 @@ def test_required_and_optional_typed_dict_keys_are_pinned(self): ) ) + def test_stub_promises_only_plain_mapping_inputs(self): + tree = ast.parse(STUB.read_text(encoding="utf-8")) + self.assertFalse( + any(isinstance(node, ast.Name) and node.id == "Mapping" for node in ast.walk(tree)) + ) + def test_pep_561_and_package_metadata_files_exist(self): self.assertTrue((PACKAGE / "py.typed").is_file()) self.assertTrue((PACKAGE / "__init__.py").is_file()) From b27c5a19acfde420ecf9bd0bac675a1a0cc4bfda Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 21:16:21 +0700 Subject: [PATCH 6/9] docs: add Relay client API guide Signed-off-by: Jeremi Joslin --- docs/site/astro.config.mjs | 1 + docs/site/scripts/check-evidence-tutorials.sh | 1 + .../site/src/content/docs/reference/index.mdx | 1 + .../docs/reference/relay-client-api.mdx | 326 ++++++++++++++++++ .../publish-governed-sqlite-registry.mdx | 2 +- .../docs/tutorials/query-relay-client.mdx | 280 +++++++++++++++ 6 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 docs/site/src/content/docs/reference/relay-client-api.mdx create mode 100644 docs/site/src/content/docs/tutorials/query-relay-client.mdx diff --git a/docs/site/astro.config.mjs b/docs/site/astro.config.mjs index a9a1384ba..1de047a11 100644 --- a/docs/site/astro.config.mjs +++ b/docs/site/astro.config.mjs @@ -432,6 +432,7 @@ export default defineConfig({ { label: 'Evidence Gateway configuration', slug: 'reference/evidence-configuration' }, { label: 'evidencectl CLI', slug: 'reference/evidencectl' }, { label: 'relayctl CLI', slug: 'reference/relayctl' }, + { label: 'Relay client APIs', slug: 'reference/relay-client-api' }, { label: 'API reference', collapsed: true, diff --git a/docs/site/scripts/check-evidence-tutorials.sh b/docs/site/scripts/check-evidence-tutorials.sh index 4a3c4ea1a..6a20a4f9f 100755 --- a/docs/site/scripts/check-evidence-tutorials.sh +++ b/docs/site/scripts/check-evidence-tutorials.sh @@ -94,6 +94,7 @@ EXCLUDED_EVIDENCE_TUTORIALS=( move-evidence-to-production-signing # drift-checked by evidence-production-build-docs.test.mjs; needs a Transit signer prove-an-evidence-project # how-to against the reader's own project; no fixed scenario this gate can replay publish-governed-sqlite-registry # Relay V2 journey; replayed by the Relay product and real-process acceptance gates + query-relay-client # Relay client journey; depends on a released wheel and the Relay publishing prerequisite request-a-holder-bound-credential # draft: true, hidden from the sidebar; no verified wallet flow exists to replay rotate-evidence-signing-keys # drift-checked by evidence-production-build-docs.test.mjs; needs a deployed signing key verify-a-registered-parent-with-opencrvs # needs the public OpenCRVS Farajaland demo; live and opt-in, not replayed in CI diff --git a/docs/site/src/content/docs/reference/index.mdx b/docs/site/src/content/docs/reference/index.mdx index fadb59f35..151de35de 100644 --- a/docs/site/src/content/docs/reference/index.mdx +++ b/docs/site/src/content/docs/reference/index.mdx @@ -33,6 +33,7 @@ documented with the task pages rather than as a stack-wide reference. See ## Interfaces and contracts - [Instance API references](apis/) +- [Relay client APIs](relay-client-api/) - [Contracts](contracts/) - [Generated files and ownership](../generated-artifacts/) - [API stability and versioning](api-stability/) diff --git a/docs/site/src/content/docs/reference/relay-client-api.mdx b/docs/site/src/content/docs/reference/relay-client-api.mdx new file mode 100644 index 000000000..7fac5b625 --- /dev/null +++ b/docs/site/src/content/docs/reference/relay-client-api.mdx @@ -0,0 +1,326 @@ +--- +title: Relay client API reference +description: Construction, authentication, methods, outcomes, continuations, caching, raw documents, and errors for the Rust, Python, and Node Relay V2 clients. +status: draft +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-11" +doc_type: reference +locale: en +standards_referenced: + - openapi + - sdmx +--- + +The Registry Relay clients cover the fixed Relay V2 HTTP surface without importing a deployment's +record, selector, filter, artifact, or SDMX schemas. Rust is the canonical implementation. The +Python and Node packages are thin bindings that return native plain values and keep HTTP, route, +authentication, Problem, trace, and response validation in Rust. + +## Availability and compatibility + +Registry Stack is pre-1.0 Beta software. Keep a client on the same Registry Stack release as the +Relay deployment unless a release note states a wider compatibility range. + +Beginning with Registry Stack v0.19.1, the release workflow attaches three Python wheels and three +Node tarballs to the matching GitHub Release. It does not publish them to PyPI or npm. Registry +Stack v0.19.0 has no prebuilt Relay client packages. The Rust crate has `publish = false`, remains +source-only in the Registry Stack workspace, and is not published to crates.io. + +Python requires version 3.10 or later and uses `abi3-py310`. Node requires version 22.12 or later. +The release assets cover Linux amd64, Linux arm64, and macOS arm64. + +## Install a client + +Download Python and Node packages from the GitHub Release that matches the Relay deployment. The +Python asset is named +`registry_relay_client--cp310-abi3-.whl`. Install the local file: + +```sh +python -m pip install ./registry_relay_client--cp310-abi3-.whl +``` + +Use `linux_x86_64`, `linux_aarch64`, or `macosx_11_0_arm64` for ``. The Node asset +is named `relay-client-node-v-.tgz`. Install it into an application: + +```sh +npm install ./relay-client-node-v-.tgz +``` + +Node package platforms are `linux-amd64-glibc`, `linux-arm64-glibc`, and `macos-arm64`. Rust +applications take the source crate from the matching release tag because it is not a registry +package: + +```toml +[dependencies] +registry-relay-client = { git = "https://github.com/registrystack/registry-stack", tag = "v" } +``` + +## Construct a client + +All three clients accept a prefix-bearing base URL. HTTPS is required except for loopback HTTP. +The URL cannot contain credentials, a query, a fragment, or ambiguous empty path segments. + +### Rust + +```rust +use registry_relay_client::{RelayClient, RelayClientConfig}; +use url::Url; + +let config = RelayClientConfig::new( + Url::parse("https://relay.example.invalid/institution-a")?, +); +let client = RelayClient::new(config)?; +``` + +`RelayClientConfig` also provides `with_token_provider`, `with_request_timeout`, +`with_connect_timeout`, `with_max_response_bytes`, `with_user_agent`, and +`with_trusted_root_certificates`. Rust methods are asynchronous. + +### Python + +```python +from registry_relay_client import RelayClient + +client = RelayClient( + base_url="https://relay.example.invalid/institution-a", + request_timeout_seconds=30, + connect_timeout_seconds=10, + max_response_bytes=8 * 1024 * 1024, +) +``` + +Python methods are synchronous. They release the global interpreter lock while the private Tokio +runtime waits for network I/O. `trusted_root_certificates` accepts a PEM bundle as `bytes`. + +### Node + +```js +const { RelayClient } = require('@registrystack/relay-client'); + +const client = new RelayClient({ + baseUrl: 'https://relay.example.invalid/institution-a', + requestTimeoutMilliseconds: 30_000, + connectTimeoutMilliseconds: 10_000, + maxResponseBytes: 8 * 1024 * 1024, +}); +``` + +Node methods return promises. `trustedRootCertificates` accepts a PEM bundle as a string. + +## Authentication + +Authentication is optional. Probes and OpenAPI never ask a configured token provider for a token +and never send authorization. Other methods attach a bearer when a provider is configured and +Relay accepts credentials for that operation. + +| Mode | Rust | Python | Node | +| --- | --- | --- | --- | +| No bearer | Omit `with_token_provider` | Omit `authorization` | Omit `authorization` | +| Static bearer | `Arc::new(StaticToken::new(token)?)` passed to `with_token_provider` | `authorization=token` | `authorization: { static: token }` | +| Private-key JWT | `PrivateKeyJwt` passed as a `TokenProvider` | `authorization={"private_key_jwt": config}` | `authorization: { privateKeyJwt: config }` | +| Custom provider | Implement the public `TokenProvider` trait | Not exposed | Not exposed | + +The built-in private-key-JWT configuration requires a token endpoint, client identifier, and +private signing JWK. It also accepts audience, assertion lifetime, refresh margin, token request +and connection timeouts, user agent, and a PEM certificate bundle for the token endpoint. Relay +and token-endpoint certificate bundles are independent. + +Python wraps the configuration as `{"private_key_jwt": {...}}` and names its required members +`token_endpoint`, `client_id`, and `client_key`. Its optional members are `audience`, +`assertion_lifetime_seconds`, `refresh_margin_seconds`, `request_timeout_seconds`, +`connect_timeout_seconds`, `user_agent`, and byte-valued `trusted_root_certificates`. Node wraps +the configuration as `{ privateKeyJwt: {...} }`. Its required members are `tokenEndpoint`, +`clientId`, and `clientKey`; optional members are `audience`, `assertionLifetimeSeconds`, +`refreshMarginSeconds`, `requestTimeoutMilliseconds`, `connectTimeoutMilliseconds`, `userAgent`, +and string-valued `trustedRootCertificates`. Rust constructs `PrivateKeyJwtConfig`, builds +`PrivateKeyJwt`, and passes it through `with_token_provider`. + +The built-in token exchange sends only `grant_type`, `client_assertion_type`, and +`client_assertion`. It does not send scope, an RFC 8707 resource, a body `client_id`, or +deployment-defined form members. Use a pre-acquired short-lived static bearer when an issuer +requires those members. Rust callers can implement a custom `TokenProvider` instead. + +## Operation matrix + +| Operation | Rust | Python | Node | +| --- | --- | --- | --- | +| Process probes | `health`, `ready` | `health`, `ready` | `health`, `ready` | +| Discovery documents | `openapi`, `service_metadata` | `openapi`, `service_metadata` | `openapi`, `serviceMetadata` | +| Resource discovery | `resources`, `continue_resources`, `resource` | `resources`, `continue_resources`, `resource` | `resources`, `continueResources`, `resource` | +| Record list and search | `list_records`, `search_records`, `continue_collection` | `list_records`, `search`, `continue_list_records`, `continue_search` | `listRecords`, `search`, `continueListRecords`, `continueSearch` | +| Record read | `read_record` | `read_record` | `readRecord` | +| Governed lookup | `lookup_record` | `lookup` | `lookup` | +| Generated artifact | `artifact` | `artifact` | `artifact` | +| SDMX documents | `sdmx_data`, `sdmx_structure` | `sdmx_data`, `sdmx_structure` | `sdmxData`, `sdmxStructure` | + +Every method performs at most one Relay service exchange. A configured private-key-JWT provider +can make a separate token-endpoint exchange when it needs to acquire or refresh a bearer. No +client follows redirects, reads ambient proxy configuration, retries a Relay exchange, fetches +referenced schemas, or advances pagination automatically. + +## Request arguments + +The table uses the wire-level fact name. Python spells multiword keyword arguments in snake case, +such as `page_size`, `access_profile`, and `dimension_at_observation`. Node places optional request +facts in an options object and uses camel case, such as `pageSize`, `accessProfile`, and +`dimensionAtObservation`. Rust uses the request types named in the final column. + +`health` and `ready` take no arguments. + +| Operation | Required arguments | Optional arguments | Rust request type | +| --- | --- | --- | --- | +| OpenAPI, service metadata | None | ETag | `Option<&StrongEtag>` | +| Resource list | None | Page size from 1 through 100; ETag | `ResourceListRequest` | +| Resource detail | Resource identifier | ETag | Resource identifier as `&str` | +| Resource continuation | Complete resource continuation | ETag | `ResourceContinuation` | +| Record list or search | Resource identifier; search identifier for search | Page size, record options, filters, bbox, ETag | `CollectionRequest` | +| Record or search continuation | Complete matching continuation | ETag | `CollectionContinuation` | +| Record read | Resource and record identifiers | Record options; ETag | `RecordOptions` | +| Governed lookup | Resource and lookup identifiers; selectors | Record options; ETag | `LookupRequest` | +| Artifact | Artifact identifier | ETag | Identifier as `&str` | +| SDMX data | Agency, resource, three-part version | Key, constraints, offset, limit, dimension at observation, format, ETag | `SdmxDataRequest` | +| SDMX structure | Kind, agency, resource, three-part version | ETag | `SdmxStructureRequest` | + +`RecordOptions` contains fields, access profile, and record format. `CollectionRequest` adds a +positive page size, a string-to-string filter mapping, and an optional bounding box ordered as +`[west, south, east, north]`. Rust constructs those with `RecordOptions`, `CollectionRequest`, and +`BoundingBox`. Python passes the same facts as keyword arguments. Node passes them in +`RecordOptions` or `CollectionOptions` objects. + +Input record formats are `json`, `json-ld`, `geojson`, and `json-fg` in Python. Node also accepts +`geo-json-rfc7946` as an alias for `geojson`. Rust uses `RecordFormat::Json`, `JsonLd`, +`GeoJsonRfc7946`, or `JsonFg`. + +A lookup selector mapping must contain at least one named string, signed integer, or boolean +value. Python supplies it as `selectors` and Node as the third `lookup` argument. Rust builds it +with one or more `LookupRequest::selector` calls. + +SDMX data format is `json` or `csv`. The optional constraints are a string-to-string component +mapping. Python passes all SDMX data facts as arguments to `sdmx_data`; Node passes one +`SdmxDataRequest` object; Rust constructs `SdmxDataRequest::new(agency, resource, version)` and +uses its builders. SDMX structure kind is `dataflow` or `datastructure` in Python, +`dataflow`, `datastructure`, or `data-structure` in Node, and `SdmxStructureKind::Dataflow` or +`DataStructure` in Rust. Every SDMX version has the exact `x.y.z` form. + +Every conditional Rust method takes an optional final `&StrongEtag`. Python exposes `etag` as the +last optional keyword. Node exposes it as the final optional argument after any options or request +object. Continuation methods accept only the matching complete continuation and optional ETag. + +## Response outcomes + +Process probes return complete responses only. Cacheable operations return one of two outcomes: + +- Rust returns `Conditional::Complete(Complete)` or + `Conditional::NotModified(NotModified)`. +- Python returns a mapping with `kind: "complete"`, `value`, `trace_id`, and optional `etag`, or + `kind: "not_modified"`, `etag`, and `trace_id`. +- Node returns an object with `kind: 'complete'`, `value`, `traceId`, and optional `etag`, or + `kind: 'notModified'`, `etag`, and `traceId`. + +Deployment-defined records and collections stay dynamic. Python returns nested mappings, lists, +and scalars. Node returns plain JSON values. Rust exposes fixed envelopes around a dynamic JSON +record body. Fixed service metadata, resource metadata, page, trace, and ETag envelopes remain +typed. + +## Continuations + +Resource discovery returns this complete continuation projection: + +```json +{ + "cursor": "" +} +``` + +A record-list continuation has this shape: + +```json +{ + "route": { + "kind": "records", + "resource": "" + }, + "cursor": "", + "format": "json", + "accessProfile": "" +} +``` + +A search continuation changes `route` to include `kind: "search"` and its exact `search` +identifier. `accessProfile` is absent when the first request did not select one. `format` is one of +`json`, `json-ld`, `geojson-rfc7946`, or `json-fg`. + +Pass the complete returned projection unchanged to the matching continuation method. A +continuation binds the opaque cursor to its resource or search route, representation format, and +optional access profile. It intentionally does not carry first-page fields, filters, bounding +box, or page size. The clients reject raw cursor strings, extra members, wrong-route handoff, and +attempts to combine a cursor with first-page choices. + +## Conditional cache contract + +A complete cacheable response can carry a validated strong ETag. It has exactly one quoted, +lowercase SHA-256 value. Pass it through the method's ETag argument to send `If-None-Match`. + +A valid `304 Not Modified` response has an empty body, echoes the requested ETag, and carries the +same strictly validated trace context as a complete response. Reuse a stored body only when it was +stored with that exact ETag. The clients do not retain bodies or issue conditional requests on +their own. + +## Raw documents + +OpenAPI, generated artifacts, SDMX data, and SDMX structures remain raw protocol documents: + +| Language | Complete raw response | +| --- | --- | +| Rust | `RawDocument` with `media_type()` and `as_bytes()` | +| Python | `body: bytes` and `media_type: str` | +| Node | `body: Buffer` and `mediaType: string` | + +The client validates exact OpenAPI and SDMX media types before returning bytes. An artifact can +declare any single syntactically valid media type, which the client preserves. Every raw method +enforces its body bound. The client does not interpret deployment-defined artifacts or SDMX +payloads. SDMX versions use the exact `x.y.z` form. + +## Error contract + +Rust returns `RelayClientError` variants for configuration, invalid request, token, transport, +Relay Problem, and protocol failures. Python throws `RelayClientError` with snake-case +attributes. Node throws the same named class with camel-case attributes. + +| Meaning | Python | Node | +| --- | --- | --- | +| Closed failure category | `kind` | `kind` | +| Registered Problem code | `code` | `code` | +| Public HTTP status | `status` | `status` | +| Validated trace identifier | `trace_id` | `traceId` | +| Bounded `429` delay | `retry_after_seconds` | `retryAfterSeconds` | +| Transport subcategory | `transport_kind` | `transportKind` | +| Token subcategory | `token_kind` | `tokenKind` | + +Optional error attributes are absent or `None` when the failure does not carry that fact. A Relay +Problem is accepted only when its exact six-member document, registered code and status, response +media type, and header/body trace agree. Only a registered `429` Problem can expose a numeric +`Retry-After` from 1 through 60 seconds. + +Errors retain fixed local reasons, public status and Problem codes, validated trace identifiers, +and bounded retry guidance. They do not expose credentials, JWKs, selectors, filters, response +bodies, header values, URLs, or underlying HTTP error chains. + +## Shared implementation boundary + +The canonical Rust client owns product-neutral outbound policy, bearer acquisition, prefix-safe +route construction, bounded reads, OAuth response decoding, trace validation, and Problem +validation. Relay-specific request models and fixed route semantics stay in the Relay client. +Deployment-defined data stays dynamic at the SDK boundary. + +The Python and Node bindings convert native values, construct the Rust client, run one SDK method, +and map the validated result or error. They do not implement Python or JavaScript HTTP, route, +authentication, Problem, redirect, retry, cache, or pagination policy. + +## Related reference + +- [Relayctl command reference](../relayctl/) for project authoring and package commands. +- [Instance API references](../apis/) for the deployment-generated Relay OpenAPI boundary. +- [Errors and status codes](../errors/) for the wider Registry Stack error vocabulary. diff --git a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx index a74f0af51..dc17c08a1 100644 --- a/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx +++ b/docs/site/src/content/docs/tutorials/publish-governed-sqlite-registry.mdx @@ -29,7 +29,7 @@ never sees the registrar's internal notes at all. outcome="One public register API serving two reviewed fields, refusing a third, with an audit line for the answer it gave." time="About 30 minutes" level="Local development with synthetic data" - prerequisites={['The sqlite3 command line', 'A shell with curl', 'An editor', 'Linux amd64, the platform the relay installer supports']} + prerequisites={['The sqlite3 command line', 'A shell with curl', 'The OpenSSL command line', 'An editor', 'Linux amd64, the platform the relay installer supports']} /> ## Understand the flow diff --git a/docs/site/src/content/docs/tutorials/query-relay-client.mdx b/docs/site/src/content/docs/tutorials/query-relay-client.mdx new file mode 100644 index 000000000..74fc2ef06 --- /dev/null +++ b/docs/site/src/content/docs/tutorials/query-relay-client.mdx @@ -0,0 +1,280 @@ +--- +title: Query Registry Relay with Python +description: Install the thin Python client, read a synthetic business record, handle an optional continuation, revalidate a document, and handle a Relay refusal. +status: draft +draft: true +owner: registry-docs +source_repos: + - registry-stack +last_reviewed: "2026-08-11" +doc_type: tutorial +persona: + - consumer or verifier +locale: en +standards_referenced: + - openapi +--- + +{/* This tutorial is not registered with an executable docs runner. Run reader-mode verification before changing status to current. */} + +import QuickstartMeta from '../../../components/QuickstartMeta.astro'; + +The Registry Relay Python client gives a consumer or verifier one synchronous method for each +fixed Relay V2 operation. In this tutorial you will install a released wheel, read one synthetic +business record, advance discovery only when Relay returns a continuation, revalidate OpenAPI +with a strong entity tag, and inspect a governed refusal. + + + +## Before you start + +Complete [Publish a governed SQLite registry](../publish-governed-sqlite-registry/) through +the **Serve it** step. Leave Relay running and open a second shell. The resulting deployment is +anonymous, contains synthetic records only, and listens at `http://127.0.0.1:8080`. + +Prebuilt Relay client packages start with Registry Stack v0.19.1. Registry Stack v0.19.0 has no +prebuilt Python wheel. The running Relay must therefore be v0.19.1 or later. Install the client +from that exact Relay release, using the wheel that matches this machine. + +## Install the client and read a record + +### Do + +Create an isolated directory and virtual environment. The shell reads the exact release version +from the running Relay and selects the wheel for the current machine. + +```sh +mkdir relay-python-query +cd relay-python-query +python3 -m venv .venv +. .venv/bin/activate + +VERSION="$(relay --version | awk '{print $2}' | sed 's/^v//')" +case "$(uname -s)-$(uname -m)" in + Linux-x86_64) WHEEL_PLATFORM="linux_x86_64" ;; + Linux-aarch64|Linux-arm64) WHEEL_PLATFORM="linux_aarch64" ;; + Darwin-arm64) WHEEL_PLATFORM="macosx_11_0_arm64" ;; + *) echo "No prebuilt Relay client wheel for this platform" >&2; exit 1 ;; +esac +WHEEL="registry_relay_client-${VERSION}-cp310-abi3-${WHEEL_PLATFORM}.whl" +curl -fLO "https://github.com/registrystack/registry-stack/releases/download/v${VERSION}/${WHEEL}" +python -m pip install "./${WHEEL}" +``` + +Create `query_relay.py`: + +```python +import json + +from registry_relay_client import RelayClient + + +client = RelayClient(base_url="http://127.0.0.1:8080") +result = client.read_record("registered-business", "BIZ-0001") + +print(result["kind"]) +print(result["value"]["data"]["recordIdentifier"]) +print(json.dumps(result["value"]["data"]["domainData"], sort_keys=True)) +``` + +Run it: + +```sh +python query_relay.py +``` + +### See + +```text +complete +BIZ-0001 +{"legalForm": "COOPERATIVE", "legalName": "Aurora Freight Cooperative"} +``` + +### Understand + +The release workflow attaches Python wheels to the matching GitHub Release. It does not publish +this package to PyPI. The wheel uses the Python 3.10 stable ABI and can be imported by supported +newer Python versions. + +`complete` distinguishes a returned representation from a cache revalidation response. The +record envelope remains a plain Python mapping. Its `domainData` contains only the two fields +disclosed by the synthetic Relay contract. + +### Adapt + +The release does not provide a Windows or Intel macOS wheel. Pass `fields=["legalName"]` to +`read_record` to narrow the response. A caller cannot use fields to widen the contract's +disclosure profile. Use the [Relay client API reference](../../reference/relay-client-api/) +before adding authentication or private certificate roots. + +## Handle an optional continuation exactly + +### Do + +Append this discovery loop to `query_relay.py`, then run the file again: + +```python +page = client.resources(page_size=1) + +while True: + for resource in page["value"]["items"]: + print(resource["resourceIdentifier"]) + + continuation = page["continuation"] + if continuation is None: + break + + page = client.continue_resources(continuation) +``` + +### See + +The new final line is: + +```text +registered-business +``` + +### Understand + +This deployment has one resource, so its first page has no continuation. On a deployment with +more resources, `continuation` is exactly `{"cursor": ""}`. Pass that returned +mapping unchanged to `continue_resources`. Do not extract its cursor, rebuild the mapping, or add +first-page options. + +Record-list and search pages use route-bound continuation mappings. Hand those unchanged to +`continue_list_records` or `continue_search`, respectively. The client never advances a page on +its own. + +### Adapt + +Persist a continuation only as the complete returned mapping. The matching continuation method +validates it again, so a resource, record-list, or search continuation cannot be substituted for +another route. + +## Revalidate OpenAPI with a strong entity tag + +### Do + +Append this conditional request and run the file: + +```python +first = client.openapi() +print(first["kind"]) +print(first["etag"] is not None) + +second = client.openapi(etag=first["etag"]) +print(second["kind"]) +print(second["etag"] == first["etag"]) +``` + +### See + +The four new lines are: + +```text +complete +True +not_modified +True +``` + +### Understand + +The first response carries raw OpenAPI bytes and a validated strong ETag. The second call sends +that tag as `If-None-Match`. Relay answers `304 Not Modified`, and the binding returns a +`not_modified` outcome with the echoed tag and trace identifier instead of a body. + +### Adapt + +Store the complete response body and ETag together. Reuse the stored body only when the next +outcome is `not_modified` and its ETag matches. Conditional requests remain explicit for every +cacheable method. + +## Handle a Relay refusal + +### Do + +Append this request for a missing synthetic record and run the file: + +```python +from registry_relay_client import RelayClientError + + +try: + client.read_record("registered-business", "BIZ-9999") +except RelayClientError as error: + print(error.kind) + print(error.status) + print(error.code) +``` + +### See + +The three new lines are: + +```text +problem +404 +consultation.unresolved +``` + +### Understand + +A valid Relay Problem becomes `RelayClientError` with stable, value-free attributes. The client +does not include response bodies, request selectors, credentials, URLs, or header values in the +error. Only a registered `429` Problem can carry `retry_after_seconds`. + +### Adapt + +Branch on `kind`, then use `status` and `code` when they are present. Treat `trace_id` as the +correlation value for an operator. Decide at the application boundary whether a request is safe +to repeat because the client performs no automatic retries. + +## Clean up + +Leave the virtual environment and remove the tutorial directory: + +```sh +deactivate +cd .. +rm -rf relay-python-query +``` + +Stop Relay with `Ctrl+C` in its shell if it was running only for this tutorial. The synthetic +registry project remains available for later Relay exercises. + +## What you built + +- A synchronous Python consumer that delegates Relay routing, response validation, and bounded + body handling to the canonical Rust client. +- An explicit branch that passes an optional continuation unchanged when Relay returns one, with + no implicit pagination. +- A strong-ETag revalidation path that distinguishes complete and `304` outcomes. +- A refusal path that uses stable error facts without exposing request or credential values. + +## Next + +- [Read the Relay client API reference](../../reference/relay-client-api/) for every Rust, Python, + and Node operation and outcome shape. +- [Author a Registry Relay project](../../configure/relay/) to add list, lookup, search, artifact, + and SDMX operations to a deployment. +- [Review errors and status codes](../../reference/errors/) before mapping failures into an + application-facing API. + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| GitHub returns `404` for the wheel | The running Relay is older than v0.19.1, its exact release is not published, or the platform name is wrong | Use a published v0.19.1 or later Relay, then download the wheel from that exact release for one of the three platforms in this tutorial. | +| `No matching distribution found` | The wheel filename or local path does not match the downloaded asset | Keep the original release filename and install that exact local file. | +| `Connection refused` | The synthetic Relay is not listening on port 8080 | Return to the publishing tutorial, start Relay, and leave it running in its shell. | +| `configuration` at construction | The base URL is not HTTPS or loopback HTTP, or it contains credentials, a query, or a fragment | Use `http://127.0.0.1:8080` for this local deployment. | +| `protocol` during a response | Relay returned a response outside the fixed media type, trace, ETag, Problem, or body contract | Record `trace_id` when present and inspect the Relay operator logs. Do not parse the rejected body in application code. | +| A repeated call returns `not_modified` | The supplied strong ETag still identifies the current representation | Reuse the body stored with that same ETag. | From c26063e49a50b57244fe9b8ca62949f8831c152a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 21:37:11 +0700 Subject: [PATCH 7/9] fix(relay-client): address final review findings Signed-off-by: Jeremi Joslin --- .github/scripts/ci_changes.py | 3 +- .github/scripts/test_ci_changes.py | 6 + crates/registry-relay-client-node/README.md | 11 ++ .../__test__/binding.test.js | 61 +++++++- .../__test__/drift.test.js | 14 ++ .../__test__/facade.test-d.ts | 19 +++ crates/registry-relay-client-node/client.d.ts | 12 +- crates/registry-relay-client-node/client.js | 3 +- crates/registry-relay-client-node/index.d.ts | 2 +- .../registry-relay-client-node/package.json | 2 +- crates/registry-relay-client-node/src/lib.rs | 112 +++++++++----- crates/registry-relay-client-py/README.md | 19 ++- .../python/registry_relay_client/__init__.pyi | 4 +- crates/registry-relay-client-py/src/lib.rs | 55 +++---- .../tests/python/test_drift.py | 42 ++++++ .../tests/python/test_happy_path.py | 14 +- .../tests/python/test_pagination.py | 10 +- .../tests/python/test_request_shapes.py | 65 +++++++++ crates/registry-relay-client/README.md | 10 +- crates/registry-relay-client/src/client.rs | 35 +++-- crates/registry-relay-client/src/query.rs | 138 +++++++++++++++--- crates/registry-relay-client/src/transport.rs | 18 +++ .../tests/http_boundary.rs | 17 ++- .../tests/acceptance_http.rs | 22 +-- .../docs/reference/relay-client-api.mdx | 13 +- 25 files changed, 562 insertions(+), 145 deletions(-) create mode 100644 crates/registry-relay-client-node/__test__/facade.test-d.ts create mode 100644 crates/registry-relay-client-py/tests/python/test_request_shapes.py diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index e2d9a96c7..456fa201d 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -456,7 +456,7 @@ def classify( # description. "products/relay-v2/CONCEPT.md", "products/relay-v2/STANDARDS-ALIGNMENT.md", - # No page is generated from these four, but scripts/ + # No page is generated from these files, but scripts/ # ops-posture-spec.test.mjs reads them to prove the published # operational claims still match the runtime. A probe route, a # runtime bound, or a healthcheck default can change here and @@ -466,6 +466,7 @@ def classify( "crates/registry-relay-v2/src/main.rs", "crates/registry-relay-v2/src/contract.rs", "crates/registry-relay-v2/src/startup.rs", + "crates/registry-relay-http-contract/src/lib.rs", } for path in paths ) diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 72ca78253..473f81cb2 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -927,6 +927,12 @@ def test_relay_docs_routing_matrix(self) -> None: "crates/registry-relay-v2/src/server.rs", {"docs": True, "relay_v2_contracts": True}, ), + ( + # The same test reads the shared probe-route constants from + # the standalone HTTP contract. + "crates/registry-relay-http-contract/src/lib.rs", + {"docs": True, "relay_client_contracts": True}, + ), ( # A Relay V2 source no docs test reads stays out of the docs job. "crates/registry-relay-v2/src/api.rs", diff --git a/crates/registry-relay-client-node/README.md b/crates/registry-relay-client-node/README.md index 7d030305e..6ea8cc7bd 100644 --- a/crates/registry-relay-client-node/README.md +++ b/crates/registry-relay-client-node/README.md @@ -24,6 +24,17 @@ const second = first.kind === 'complete' && first.continuation : null; ``` +List options may contain declared equality `filters` but never `bbox`. Named +searches instead require a `[west, south, east, north]` WGS84 `bbox` and do not +accept equality filters: + +```js +const premises = await client.search('premises', 'within-bbox', { + bbox: [100.45, 13.65, 100.65, 13.85], + pageSize: 25, +}); +``` + Resource discovery similarly returns a closed `{ cursor }` continuation object for `continueResources`; raw cursor strings are not accepted. diff --git a/crates/registry-relay-client-node/__test__/binding.test.js b/crates/registry-relay-client-node/__test__/binding.test.js index dbb2ab713..e0fb642b8 100644 --- a/crates/registry-relay-client-node/__test__/binding.test.js +++ b/crates/registry-relay-client-node/__test__/binding.test.js @@ -100,7 +100,6 @@ test('every endpoint accepts its documented plain input graph', async () => { accessProfile: 'public', format: 'geojson', filters: { status: 'active' }, - bbox: [-10, -5, 10, 5], }, ETAG)); await expectNotModified(client.continueListRecords({ route: { kind: 'records', resource: 'people' }, @@ -120,7 +119,7 @@ test('every endpoint accepts its documented plain input graph', async () => { }, { format: 'json' }, ETAG)); await expectNotModified(client.search('people', 'by-name', { pageSize: 10, - filters: { status: 'active' }, + bbox: [-10, -5, 10, 5], }, ETAG)); await expectNotModified(client.continueSearch({ route: { kind: 'search', resource: 'people', search: 'by-name' }, @@ -155,6 +154,64 @@ test('request validation failures have a distinct stable kind', async () => { ); }); +test('list and search runtime options preserve their distinct query shapes', async () => { + const client = new RelayClient({ baseUrl }); + for (const promise of [ + client.listRecords('people', { bbox: [-10, -5, 10, 5] }), + client.search('people', 'within-bbox', { pageSize: 10 }), + client.search('people', 'within-bbox', { + bbox: [-10, -5, 10, 5], + filters: { status: 'active' }, + }), + ]) { + await assert.rejects( + promise, + (error) => error instanceof RelayClientError && error.kind === 'invalid_request', + ); + } + assert.throws( + () => client.search('people', 'within-bbox', undefined), + (error) => error instanceof RelayClientError + && error.kind === 'invalid_request' + && error.message === 'Relay client arguments are invalid', + ); +}); + +test('error facts omit nulls while preserving present falsy values', () => { + const absent = new RelayClientError({ + kind: 'protocol', + message: 'Relay response violated the protocol', + code: null, + status: null, + traceId: null, + retryAfterSeconds: null, + transportKind: null, + tokenKind: null, + }); + for (const field of [ + 'code', 'status', 'traceId', 'retryAfterSeconds', 'transportKind', 'tokenKind', + ]) { + assert.equal(Object.hasOwn(absent, field), false); + } + + const present = new RelayClientError({ + kind: 'problem', + message: 'Relay refused the request', + code: 'resource.not_found', + status: 0, + traceId: TRACE_ID, + retryAfterSeconds: 0, + transportKind: 'connect', + tokenKind: 'transport', + }); + assert.equal(present.code, 'resource.not_found'); + assert.equal(present.status, 0); + assert.equal(present.traceId, TRACE_ID); + assert.equal(present.retryAfterSeconds, 0); + assert.equal(present.transportKind, 'connect'); + assert.equal(present.tokenKind, 'transport'); +}); + test('synchronous napi argument conversion failures use fixed redacted envelopes', () => { const client = new RelayClient({ baseUrl }); for (const invoke of [ diff --git a/crates/registry-relay-client-node/__test__/drift.test.js b/crates/registry-relay-client-node/__test__/drift.test.js index 1a5e04014..0c2dbd25e 100644 --- a/crates/registry-relay-client-node/__test__/drift.test.js +++ b/crates/registry-relay-client-node/__test__/drift.test.js @@ -52,6 +52,20 @@ test('continuation format literals match the core wire projection', () => { assert.doesNotMatch(continuation[0], /'geo-json-rfc7946'/); }); +test('list and search declarations expose distinct closed option shapes', () => { + const declaration = fs.readFileSync(path.join(__dirname, '..', 'client.d.ts'), 'utf8'); + const list = declaration.match(/export interface ListOptions[\s\S]*?\n}/); + const search = declaration.match(/export interface SearchOptions[\s\S]*?\n}/); + assert.ok(list); + assert.ok(search); + assert.match(list[0], /filters\?:/); + assert.doesNotMatch(list[0], /\bbbox\??:/); + assert.match(search[0], /\bbbox:/); + assert.doesNotMatch(search[0], /filters\?:/); + assert.match(declaration, /listRecords\(resource: string, options\?: ListOptions \| null,/); + assert.match(declaration, /search\(resource: string, search: string, options: SearchOptions,/); +}); + test('only the normalized package entry point is exported', () => { assert.equal(require('@registrystack/relay-client').RelayClient, wrapper.RelayClient); assert.throws( diff --git a/crates/registry-relay-client-node/__test__/facade.test-d.ts b/crates/registry-relay-client-node/__test__/facade.test-d.ts new file mode 100644 index 000000000..5ba5830c8 --- /dev/null +++ b/crates/registry-relay-client-node/__test__/facade.test-d.ts @@ -0,0 +1,19 @@ +import { ListOptions, RelayClient, SearchOptions } from '..' + +declare const client: RelayClient + +const listOptions: ListOptions = { filters: { status: 'active' }, pageSize: 25 } +const searchOptions: SearchOptions = { bbox: [100.45, 13.65, 100.65, 13.85], pageSize: 25 } + +client.listRecords('people', listOptions) +client.search('premises', 'within-bbox', searchOptions) + +// @ts-expect-error List operations do not accept spatial query facts. +client.listRecords('people', { bbox: [100.45, 13.65, 100.65, 13.85] }) +// @ts-expect-error Search options and their bbox are required. +client.search('premises', 'within-bbox') +client.search('premises', 'within-bbox', { + bbox: [100.45, 13.65, 100.65, 13.85], + // @ts-expect-error Search operations do not accept equality filters. + filters: { status: 'active' }, +}) diff --git a/crates/registry-relay-client-node/client.d.ts b/crates/registry-relay-client-node/client.d.ts index c39688c16..bbdfc17a8 100644 --- a/crates/registry-relay-client-node/client.d.ts +++ b/crates/registry-relay-client-node/client.d.ts @@ -51,11 +51,15 @@ export interface RecordOptions { format?: RecordFormat | null } -export interface CollectionOptions extends RecordOptions { +export interface ListOptions extends RecordOptions { pageSize?: number | null filters?: Readonly> | null +} + +export interface SearchOptions extends RecordOptions { + pageSize?: number | null /** `[west, south, east, north]` in WGS84 longitude/latitude degrees. */ - bbox?: readonly [number, number, number, number] | null + bbox: readonly [number, number, number, number] } export type LookupSelector = string | number | boolean @@ -234,11 +238,11 @@ export declare class RelayClient { resources(options?: ResourceListOptions | null, etag?: string | null): Promise continueResources(continuation: ResourceContinuation, etag?: string | null): Promise resource(resource: string, etag?: string | null): Promise> - listRecords(resource: string, options?: CollectionOptions | null, etag?: string | null): Promise + listRecords(resource: string, options?: ListOptions | null, etag?: string | null): Promise continueListRecords(continuation: CollectionContinuation, etag?: string | null): Promise readRecord(resource: string, recordIdentifier: string, options?: RecordOptions | null, etag?: string | null): Promise> lookup(resource: string, lookup: string, selectors: LookupSelectors, options?: RecordOptions | null, etag?: string | null): Promise> - search(resource: string, search: string, options?: CollectionOptions | null, etag?: string | null): Promise + search(resource: string, search: string, options: SearchOptions, etag?: string | null): Promise continueSearch(continuation: CollectionContinuation, etag?: string | null): Promise artifact(artifactIdentifier: string, etag?: string | null): Promise sdmxData(request: SdmxDataRequest, etag?: string | null): Promise diff --git a/crates/registry-relay-client-node/client.js b/crates/registry-relay-client-node/client.js index fd335ec8b..85d4b4f4e 100644 --- a/crates/registry-relay-client-node/client.js +++ b/crates/registry-relay-client-node/client.js @@ -13,7 +13,7 @@ class RelayClientError extends Error { this.name = 'RelayClientError'; this.kind = envelope.kind; for (const field of ['code', 'status', 'traceId', 'retryAfterSeconds', 'transportKind', 'tokenKind']) { - if (envelope[field] !== undefined) this[field] = envelope[field]; + if (envelope[field] !== undefined && envelope[field] !== null) this[field] = envelope[field]; } } } @@ -150,6 +150,7 @@ const REQUIRED_JSON_ARGUMENTS = { continueResources: new Set([0]), continueListRecords: new Set([0]), lookup: new Set([2]), + search: new Set([2]), continueSearch: new Set([0]), sdmxData: new Set([0]), sdmxStructure: new Set([0]), diff --git a/crates/registry-relay-client-node/index.d.ts b/crates/registry-relay-client-node/index.d.ts index a9c98bf47..a6d75b371 100644 --- a/crates/registry-relay-client-node/index.d.ts +++ b/crates/registry-relay-client-node/index.d.ts @@ -13,7 +13,7 @@ export declare class RelayClient { continueListRecords(continuation: any, etag?: string | undefined | null): Promise readRecord(resource: string, recordIdentifier: string, options?: any | undefined | null, etag?: string | undefined | null): Promise lookup(resource: string, lookup: string, selectors: any, options?: any | undefined | null, etag?: string | undefined | null): Promise - search(resource: string, search: string, options?: any | undefined | null, etag?: string | undefined | null): Promise + search(resource: string, search: string, options: any, etag?: string | undefined | null): Promise continueSearch(continuation: any, etag?: string | undefined | null): Promise artifact(artifactIdentifier: string, etag?: string | undefined | null): Promise sdmxData(requestValue: any, etag?: string | undefined | null): Promise diff --git a/crates/registry-relay-client-node/package.json b/crates/registry-relay-client-node/package.json index fa63900ed..ab0dce227 100644 --- a/crates/registry-relay-client-node/package.json +++ b/crates/registry-relay-client-node/package.json @@ -15,7 +15,7 @@ "build": "napi build --platform --release", "build:debug": "napi build --platform", "test": "node --test __test__/*.test.js", - "check:types": "napi build --platform --release --dts index.d.ts.check && cmp index.d.ts index.d.ts.check && rm -f index.d.ts.check && tsc --noEmit --strict --skipLibCheck false --types node --moduleResolution node16 --module node16 --target es2022 client.d.ts index.d.ts" + "check:types": "napi build --platform --release --dts index.d.ts.check && cmp index.d.ts index.d.ts.check && rm -f index.d.ts.check && tsc --noEmit --strict --skipLibCheck false --types node --moduleResolution node16 --module node16 --target es2022 client.d.ts index.d.ts __test__/facade.test-d.ts" }, "devDependencies": { "@napi-rs/cli": "3.8.2", diff --git a/crates/registry-relay-client-node/src/lib.rs b/crates/registry-relay-client-node/src/lib.rs index b436ac79c..8da21b400 100644 --- a/crates/registry-relay-client-node/src/lib.rs +++ b/crates/registry-relay-client-node/src/lib.rs @@ -13,11 +13,11 @@ use napi_derive::napi; use registry_platform_crypto::PrivateJwk; use registry_relay_client::{ BoundingBox, CollectionContinuation, CollectionContinuationProjection, CollectionPage, - CollectionRequest, CollectionRouteProjection, Complete, Conditional, LookupRequest, - NotModified, PrivateKeyJwt, PrivateKeyJwtConfig, ProtocolFailure, RawDocument, RecordFormat, - RecordOptions, RelayClient as CoreClient, RelayClientConfig, RelayClientError, - ResourceContinuation, ResourceContinuationProjection, ResourceListRequest, ResourcePage, - ResponseMetadata, SdmxDataFormat, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, + CollectionRouteProjection, Complete, Conditional, ListRequest, LookupRequest, NotModified, + PrivateKeyJwt, PrivateKeyJwtConfig, ProtocolFailure, RawDocument, RecordFormat, RecordOptions, + RelayClient as CoreClient, RelayClientConfig, RelayClientError, ResourceContinuation, + ResourceContinuationProjection, ResourceListRequest, ResourcePage, ResponseMetadata, + SdmxDataFormat, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, SearchRequest, StaticToken, StrongEtag, TokenError, TokenProvider, }; use serde::Serialize; @@ -674,20 +674,13 @@ fn record_options(object: &Map) -> Result { Ok(options) } -fn collection_request(value: Option) -> Result { +fn list_request(value: Option) -> Result { let object = request_object( value, - &[ - "pageSize", - "fields", - "accessProfile", - "format", - "filters", - "bbox", - ], - "collection options must be an object with supported fields", + &["pageSize", "fields", "accessProfile", "format", "filters"], + "list options must be an object with supported fields", )?; - let mut request = CollectionRequest::default().options(record_options(&object)?); + let mut request = ListRequest::default().options(record_options(&object)?); if let Some(value) = request_optional_u32( &object, "pageSize", @@ -698,24 +691,44 @@ fn collection_request(value: Option) -> Result { for (name, value) in string_map(&object, "filters", "filters must map strings to strings")? { request = request.filter(name, value).map_err(client_error)?; } - if let Some(value) = object.get("bbox") { - if !value.is_null() { - let values = value.as_array().ok_or_else(|| { - binding_error("invalid_request", "bbox must be an array of four numbers") - })?; - let numbers = values - .iter() - .map(Value::as_f64) - .collect::>>() - .filter(|values| values.len() == 4) - .ok_or_else(|| { - binding_error("invalid_request", "bbox must be an array of four numbers") - })?; - request = request.bbox( - BoundingBox::new(numbers[0], numbers[1], numbers[2], numbers[3]) - .map_err(client_error)?, - ); - } + Ok(request) +} + +fn search_request(value: Value) -> Result { + let object = request_object( + Some(value), + &["pageSize", "fields", "accessProfile", "format", "bbox"], + "search options must be an object with supported fields", + )?; + let values = object + .get("bbox") + .and_then(Value::as_array) + .ok_or_else(|| { + binding_error( + "invalid_request", + "search options must include bbox as an array of four numbers", + ) + })?; + let numbers = values + .iter() + .map(Value::as_f64) + .collect::>>() + .filter(|values| values.len() == 4) + .ok_or_else(|| { + binding_error( + "invalid_request", + "search options must include bbox as an array of four numbers", + ) + })?; + let bbox = + BoundingBox::new(numbers[0], numbers[1], numbers[2], numbers[3]).map_err(client_error)?; + let mut request = SearchRequest::new(bbox).options(record_options(&object)?); + if let Some(value) = request_optional_u32( + &object, + "pageSize", + "pageSize must be a non-negative integer", + )? { + request = request.page_size(value).map_err(client_error)?; } Ok(request) } @@ -890,7 +903,7 @@ impl RelayClient { options: Option, etag: Option, ) -> Result> { - let request = collection_request(options)?; + let request = list_request(options)?; let etag = parse_etag(etag)?; collection_page( self.inner @@ -960,10 +973,10 @@ impl RelayClient { &self, resource: String, search: String, - options: Option, + options: Value, etag: Option, ) -> Result> { - let request = collection_request(options)?; + let request = search_request(options)?; let etag = parse_etag(etag)?; collection_page( self.inner @@ -1192,10 +1205,33 @@ mod tests { #[test] fn invalid_request_kind_is_not_configuration() { - let error = collection_request(Some(json!({"pageSize": 0}))).unwrap_err(); + let error = list_request(Some(json!({"pageSize": 0}))).unwrap_err(); assert_eq!(error_envelope(error)["kind"], "invalid_request"); } + #[test] + fn list_and_search_options_keep_distinct_query_shapes() { + let list_error = + list_request(Some(json!({"bbox": [100.0, 13.0, 101.0, 14.0]}))).unwrap_err(); + assert_eq!(error_envelope(list_error)["kind"], "invalid_request"); + + let missing_bbox = search_request(json!({"pageSize": 10})).unwrap_err(); + assert_eq!(error_envelope(missing_bbox)["kind"], "invalid_request"); + + let filter_error = search_request(json!({ + "bbox": [100.0, 13.0, 101.0, 14.0], + "filters": {"status": "active"} + })) + .unwrap_err(); + assert_eq!(error_envelope(filter_error)["kind"], "invalid_request"); + + search_request(json!({ + "pageSize": 10, + "bbox": [100.0, 13.0, 101.0, 14.0] + })) + .expect("the closed search query shape is accepted"); + } + #[test] fn continuation_route_must_match_its_consumer() { let error = collection_continuation( diff --git a/crates/registry-relay-client-py/README.md b/crates/registry-relay-client-py/README.md index 0f6eeef6e..49d1a07bf 100644 --- a/crates/registry-relay-client-py/README.md +++ b/crates/registry-relay-client-py/README.md @@ -1,9 +1,10 @@ # Registry Relay client for Python This package is the thin synchronous Python binding for -`registry-relay-client`. It performs one bounded SDK exchange per method and -does not implement HTTP routing, authentication, retries, pagination, or Relay -Problem Details itself. +`registry-relay-client`. It performs one bounded Relay SDK exchange per method; +a configured private-key-JWT provider may perform a separate token acquisition +or refresh exchange. The binding does not implement HTTP routing, +authentication, retries, pagination, or Relay Problem Details itself. ```python from registry_relay_client import RelayClient @@ -48,6 +49,18 @@ and optional `etag`, or `not_modified` with `trace_id` and `etag`. Page results also carry a plain `continuation`, which only the matching continuation method accepts. Raw OpenAPI, artifact, and SDMX bodies are returned as `bytes`. +List and search requests follow their distinct Relay contracts. A list accepts +optional equality `filters` and never accepts `bbox`. A named search requires a +four-number `bbox` ordered as west, south, east, north and never accepts +`filters`: + +```python +listed = client.list_records("people", filters={"status": "active"}) +nearby = client.search("people", "nearby", bbox=[100.0, 13.0, 101.0, 14.0]) +``` + +The client validates these shapes before performing a Relay exchange. + Build and test from the workspace root: ```sh diff --git a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi index c74246212..195716d3b 100644 --- a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi +++ b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi @@ -153,7 +153,6 @@ class RelayClient: access_profile: Optional[str] = ..., format: RecordFormat = ..., filters: Optional[dict[str, str]] = ..., - bbox: Optional[Sequence[float]] = ..., etag: Optional[str] = ..., ) -> CollectionPageOutcome: ... def continue_list_records( @@ -187,12 +186,11 @@ class RelayClient: resource: str, search: str, *, + bbox: Sequence[float], page_size: Optional[int] = ..., fields: Optional[Sequence[str]] = ..., access_profile: Optional[str] = ..., format: RecordFormat = ..., - filters: Optional[dict[str, str]] = ..., - bbox: Optional[Sequence[float]] = ..., etag: Optional[str] = ..., ) -> CollectionPageOutcome: ... def continue_search( diff --git a/crates/registry-relay-client-py/src/lib.rs b/crates/registry-relay-client-py/src/lib.rs index 57f3a32c2..2f75d9b6d 100644 --- a/crates/registry-relay-client-py/src/lib.rs +++ b/crates/registry-relay-client-py/src/lib.rs @@ -10,11 +10,11 @@ use pyo3::{ }; use relay_client_sdk::{ BoundingBox, CollectionContinuation, CollectionContinuationProjection, CollectionPage, - CollectionRequest, CollectionRouteProjection, Complete, Conditional, LookupRequest, - NotModified, RawDocument, RecordFormat, RecordOptions, RelayClient as RustClient, + CollectionRouteProjection, Complete, Conditional, ListRequest, LookupRequest, NotModified, + RawDocument, RecordFormat, RecordOptions, RelayClient as RustClient, RelayClientError as RustClientError, ResourceContinuation, ResourceContinuationProjection, ResourceListRequest, ResourcePage, ResponseMetadata, SdmxDataFormat, SdmxDataRequest, - SdmxStructureKind, SdmxStructureRequest, StrongEtag, + SdmxStructureKind, SdmxStructureRequest, SearchRequest, StrongEtag, }; use serde::Serialize; use serde_json::Value; @@ -144,10 +144,7 @@ fn string_map( .collect() } -fn bounding_box(py: Python<'_>, value: Option<&Bound<'_, PyAny>>) -> PyResult> { - let Some(value) = value else { - return Ok(None); - }; +fn bounding_box(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult { let value = python_to_json(value).map_err(|error| conversion_error(py, "invalid_request", &error))?; let Value::Array(values) = value else { @@ -168,22 +165,19 @@ fn bounding_box(py: Python<'_>, value: Option<&Bound<'_, PyAny>>) -> PyResult, page_size: Option, fields: Option>, access_profile: Option, format: &str, filters: Option<&Bound<'_, PyAny>>, - bbox: Option<&Bound<'_, PyAny>>, -) -> PyResult { +) -> PyResult { let options = record_options(py, fields, access_profile, format)?; - let mut request = CollectionRequest::default().options(options); + let mut request = ListRequest::default().options(options); if let Some(page_size) = page_size { request = request .page_size(page_size) @@ -194,8 +188,23 @@ fn collection_request( .filter(name, value) .map_err(|error| sdk_error(py, &error))?; } - if let Some(bbox) = bounding_box(py, bbox)? { - request = request.bbox(bbox); + Ok(request) +} + +fn search_request( + py: Python<'_>, + bbox: &Bound<'_, PyAny>, + page_size: Option, + fields: Option>, + access_profile: Option, + format: &str, +) -> PyResult { + let options = record_options(py, fields, access_profile, format)?; + let mut request = SearchRequest::new(bounding_box(py, bbox)?).options(options); + if let Some(page_size) = page_size { + request = request + .page_size(page_size) + .map_err(|error| sdk_error(py, &error))?; } Ok(request) } @@ -500,7 +509,7 @@ impl RelayClient { #[pyo3(signature = ( resource, *, page_size=None, fields=None, access_profile=None, format="json", - filters=None, bbox=None, etag=None + filters=None, etag=None ))] #[allow(clippy::too_many_arguments)] fn list_records<'py>( @@ -512,11 +521,9 @@ impl RelayClient { access_profile: Option, format: &str, filters: Option<&Bound<'_, PyAny>>, - bbox: Option<&Bound<'_, PyAny>>, etag: Option<&str>, ) -> PyResult> { - let request = - collection_request(py, page_size, fields, access_profile, format, filters, bbox)?; + let request = list_request(py, page_size, fields, access_profile, format, filters)?; let etag = parse_etag(py, etag)?; let value = py .detach(|| { @@ -538,8 +545,8 @@ impl RelayClient { } #[pyo3(signature = ( - resource, search, *, page_size=None, fields=None, access_profile=None, format="json", - filters=None, bbox=None, etag=None + resource, search, *, bbox, page_size=None, fields=None, access_profile=None, + format="json", etag=None ))] #[allow(clippy::too_many_arguments)] fn search<'py>( @@ -547,16 +554,14 @@ impl RelayClient { py: Python<'py>, resource: &str, search: &str, + bbox: &Bound<'_, PyAny>, page_size: Option, fields: Option>, access_profile: Option, format: &str, - filters: Option<&Bound<'_, PyAny>>, - bbox: Option<&Bound<'_, PyAny>>, etag: Option<&str>, ) -> PyResult> { - let request = - collection_request(py, page_size, fields, access_profile, format, filters, bbox)?; + let request = search_request(py, bbox, page_size, fields, access_profile, format)?; let etag = parse_etag(py, etag)?; let value = py .detach(|| { diff --git a/crates/registry-relay-client-py/tests/python/test_drift.py b/crates/registry-relay-client-py/tests/python/test_drift.py index 2d2c37b69..7e3346b69 100644 --- a/crates/registry-relay-client-py/tests/python/test_drift.py +++ b/crates/registry-relay-client-py/tests/python/test_drift.py @@ -1,6 +1,7 @@ from __future__ import annotations import ast +import inspect import pathlib import sys import unittest @@ -60,6 +61,47 @@ def test_client_methods_match_both_directions(self): live = set(vars(relay.RelayClient)) - {"__doc__", "__module__"} self.assertEqual(stub, live) + def test_list_and_search_signatures_match_both_directions(self): + for method_name in ("list_records", "search"): + with self.subTest(method=method_name): + stub_method = next( + item + for item in self.classes["RelayClient"].body + if isinstance(item, ast.FunctionDef) and item.name == method_name + ) + stub_parameters = { + argument.arg: ("positional", default is None) + for argument, default in zip( + stub_method.args.args, + [None] + * (len(stub_method.args.args) - len(stub_method.args.defaults)) + + list(stub_method.args.defaults), + ) + } + stub_parameters.update( + { + argument.arg: ("keyword", default is None) + for argument, default in zip( + stub_method.args.kwonlyargs, + stub_method.args.kw_defaults, + ) + } + ) + + live_parameters = inspect.signature( + getattr(relay.RelayClient, method_name) + ).parameters + live_projection = { + name: ( + "keyword" + if parameter.kind is inspect.Parameter.KEYWORD_ONLY + else "positional", + parameter.default is inspect.Parameter.empty, + ) + for name, parameter in live_parameters.items() + } + self.assertEqual(stub_parameters, live_projection) + def test_error_attributes_and_inheritance_are_pinned(self): self.assertEqual( class_members(self.classes["RelayClientError"]), ERROR_ATTRIBUTES diff --git a/crates/registry-relay-client-py/tests/python/test_happy_path.py b/crates/registry-relay-client-py/tests/python/test_happy_path.py index 8c26ee75b..f2f33c9c3 100644 --- a/crates/registry-relay-client-py/tests/python/test_happy_path.py +++ b/crates/registry-relay-client-py/tests/python/test_happy_path.py @@ -4,7 +4,7 @@ import pathlib import sys import unittest -from urllib.parse import urlsplit +from urllib.parse import parse_qs, urlsplit TESTS = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(TESTS)) @@ -29,7 +29,8 @@ class HappyPathTest(unittest.TestCase): def test_every_fixed_method_delegates_one_exchange_and_returns_plain_values(self): def respond(request: Request) -> Response: - path = urlsplit(request.target).path + target = urlsplit(request.target) + path = target.path if path in {"/prefix/health", "/prefix/ready"}: return json_response({"status": "ok"}) if path == "/prefix/openapi.json": @@ -46,6 +47,7 @@ def respond(request: Request) -> Response: } ) if path == "/prefix/v2/resources/people/records": + self.assertEqual(parse_qs(target.query), {"status": ["active"]}) return json_response(record_collection(None)) if path == "/prefix/v2/resources/people/records/one": return json_response({"data": record(), "meta": record_metadata()}) @@ -54,6 +56,7 @@ def respond(request: Request) -> Response: self.assertEqual(json.loads(request.body), {"selectors": {"code": "one"}}) return json_response({"data": record(), "meta": record_metadata()}) if path == "/prefix/v2/resources/people/searches/nearby": + self.assertEqual(parse_qs(target.query), {"bbox": ["10,20,11,21"]}) return json_response(record_collection(None)) if path == "/prefix/v2/artifacts/schema": return Response(200, "application/schema+json", b'{"type":"object"}') @@ -79,7 +82,12 @@ def respond(request: Request) -> Response: self.assertEqual(client.service_metadata()["value"]["name"], "Example Registry") self.assertEqual(client.resources()["value"]["items"][0]["resourceIdentifier"], "people") self.assertEqual(client.resource("people")["value"]["data"]["title"], "People") - self.assertEqual(client.list_records("people")["value"]["items"][0]["recordIdentifier"], "one") + self.assertEqual( + client.list_records("people", filters={"status": "active"})["value"][ + "items" + ][0]["recordIdentifier"], + "one", + ) self.assertEqual(client.read_record("people", "one")["value"]["data"]["domainData"]["label"], "Example") self.assertEqual(client.lookup("people", "by-code", {"code": "one"})["value"]["data"]["recordIdentifier"], "one") self.assertEqual(client.search("people", "nearby", bbox=[10, 20, 11, 21])["value"]["items"][0]["recordIdentifier"], "one") diff --git a/crates/registry-relay-client-py/tests/python/test_pagination.py b/crates/registry-relay-client-py/tests/python/test_pagination.py index 9a8ae6e67..3047e9fd5 100644 --- a/crates/registry-relay-client-py/tests/python/test_pagination.py +++ b/crates/registry-relay-client-py/tests/python/test_pagination.py @@ -74,7 +74,11 @@ def respond(request: Request): self.assertIsNone(second_records["continuation"]) first_search = client.search( - "people", "nearby", page_size=3, format="json-ld" + "people", + "nearby", + bbox=[10, 20, 11, 21], + page_size=3, + format="json-ld", ) search_continuation = first_search["continuation"] self.assertEqual(search_continuation["route"]["kind"], "search") @@ -90,7 +94,11 @@ def respond(request: Request): self.assertNotIn("fields", queries[3]) self.assertNotIn("pageSize", queries[3]) self.assertNotIn("category", queries[3]) + self.assertEqual( + queries[4], {"bbox": ["10,20,11,21"], "pageSize": ["3"]} + ) self.assertEqual(queries[5], {"cursor": ["search_cursor"]}) + self.assertNotIn("bbox", queries[5]) def test_continuations_are_route_specific_and_exact(self): client = relay.RelayClient("http://127.0.0.1:9") diff --git a/crates/registry-relay-client-py/tests/python/test_request_shapes.py b/crates/registry-relay-client-py/tests/python/test_request_shapes.py new file mode 100644 index 000000000..3f204ca22 --- /dev/null +++ b/crates/registry-relay-client-py/tests/python/test_request_shapes.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import inspect +import pathlib +import sys +import unittest + +TESTS = pathlib.Path(__file__).resolve().parent +sys.path.insert(0, str(TESTS)) +import bootstrap # noqa: E402 + +bootstrap.ensure_built() +import registry_relay_client as relay # noqa: E402 + + +class RequestShapeTest(unittest.TestCase): + def setUp(self): + self.client = relay.RelayClient("http://127.0.0.1:9") + + def test_native_list_signature_has_filters_and_no_bbox(self): + parameters = inspect.signature(relay.RelayClient.list_records).parameters + self.assertIn("filters", parameters) + self.assertNotIn("bbox", parameters) + + with self.assertRaises(TypeError): + self.client.list_records("people", bbox=[10, 20, 11, 21]) + + def test_native_search_signature_requires_bbox_and_has_no_filters(self): + parameters = inspect.signature(relay.RelayClient.search).parameters + self.assertNotIn("filters", parameters) + self.assertEqual(parameters["bbox"].kind, inspect.Parameter.KEYWORD_ONLY) + self.assertIs(parameters["bbox"].default, inspect.Parameter.empty) + + with self.assertRaises(TypeError): + self.client.search("people", "nearby") + with self.assertRaises(TypeError): + self.client.search( + "people", + "nearby", + bbox=[10, 20, 11, 21], + filters={"status": "active"}, + ) + + def test_search_rejects_invalid_bbox_without_exposing_values(self): + canary = "canary-bbox-value" + for bbox in (None, [10, 20, 11], [canary, 20, 11, 21]): + with self.subTest(bbox=bbox): + with self.assertRaises(relay.RelayClientError) as raised: + self.client.search("people", "nearby", bbox=bbox) + self.assertEqual(raised.exception.kind, "invalid_request") + self.assertEqual( + str(raised.exception), "bbox must be a four-number sequence" + ) + self.assertNotIn(canary, str(raised.exception)) + self.assertNotIn(canary, repr(raised.exception)) + + cyclic: list[object] = [] + cyclic.append(cyclic) + with self.assertRaises(relay.RelayClientError) as cycle: + self.client.search("people", "nearby", bbox=cyclic) + self.assertEqual(cycle.exception.kind, "invalid_request") + + +if __name__ == "__main__": + unittest.main() diff --git a/crates/registry-relay-client/README.md b/crates/registry-relay-client/README.md index 813e61e2c..5e490d425 100644 --- a/crates/registry-relay-client/README.md +++ b/crates/registry-relay-client/README.md @@ -42,7 +42,7 @@ shared platform client primitives. ```rust,no_run use registry_relay_client::{ - CollectionRequest, Conditional, RecordFormat, RecordOptions, + Conditional, ListRequest, RecordFormat, RecordOptions, ResourceListRequest, }; # async fn run(client: ®istry_relay_client::RelayClient) -> Result<(), registry_relay_client::RelayClientError> { @@ -54,7 +54,7 @@ let options = RecordOptions::default() .fields(["name", "status"])? .access_profile("caseworker")? .format(RecordFormat::JsonLd); -let request = CollectionRequest::default() +let request = ListRequest::default() .options(options) .page_size(25)? .filter("status", "active")?; @@ -76,8 +76,12 @@ wire format, and access profile. Its validated serializable projection supports language bindings and persistence without admitting first-page fields, filters, bbox, or page size. A caller cannot combine those facts with a cursor. +List and named search use distinct first-page types. `ListRequest` permits only +declared equality filters and has no bbox API. `SearchRequest::new(bbox)` makes +the closed point-bbox search input mandatory and exposes no filter API. + Lookups serialize exactly `{"selectors": {...}}`; selector names and scalar -values are bounded before a request is built. Search filters cannot collide +values are bounded before a request is built. List filters cannot collide with Relay's reserved query names. Field lists reject empty or duplicate names, and bounding boxes reject non-finite coordinates, invalid latitude/longitude, south-to-north inversion, and antimeridian crossing. diff --git a/crates/registry-relay-client/src/client.rs b/crates/registry-relay-client/src/client.rs index 2a1850661..e75107d3d 100644 --- a/crates/registry-relay-client/src/client.rs +++ b/crates/registry-relay-client/src/client.rs @@ -101,14 +101,14 @@ impl RelayClient { pub async fn list_records( &self, resource: &str, - request: &CollectionRequest, + request: &ListRequest, etag: Option<&StrongEtag>, ) -> Result>, RelayClientError> { validate_route_identifier(resource)?; let route = CollectionRoute::Records { resource: resource.to_owned(), }; - self.collection_page(route, request.pairs()?, request.options.clone(), etag) + self.collection_page(route, request.pairs()?, request.record_options(), etag) .await } @@ -116,7 +116,7 @@ impl RelayClient { &self, resource: &str, search: &str, - request: &CollectionRequest, + request: &SearchRequest, etag: Option<&StrongEtag>, ) -> Result>, RelayClientError> { validate_route_identifier(resource)?; @@ -125,7 +125,7 @@ impl RelayClient { resource: resource.to_owned(), search: search.to_owned(), }; - self.collection_page(route, request.pairs()?, request.options.clone(), etag) + self.collection_page(route, request.pairs()?, request.record_options(), etag) .await } @@ -493,8 +493,8 @@ impl RelayClient { Some(trace), )); } - let body = self.transport.read(response, 1).await?; - return not_modified_outcome(actual, trace, &body); + let body_is_empty = self.transport.not_modified_body_is_empty(response).await?; + return not_modified_outcome(actual, trace, body_is_empty); } let media_type = match expected_media { Some(expected) if exact_media_type(&headers, expected) => expected.to_owned(), @@ -528,9 +528,9 @@ impl RelayClient { fn not_modified_outcome( etag: StrongEtag, trace_id: registry_platform_httpsec::TraceId, - body: &[u8], + body_is_empty: bool, ) -> Result { - if !body.is_empty() { + if !body_is_empty { return Err(RelayClientError::protocol( StatusCode::NOT_MODIFIED.as_u16(), ProtocolFailure::NotModifiedBody, @@ -769,7 +769,8 @@ mod tests { let mut builder = HttpResponse::builder() .status(StatusCode::NOT_MODIFIED) .header("traceparent", TRACEPARENT) - .header(CONTENT_TYPE, APPLICATION_JSON); + .header(CONTENT_TYPE, APPLICATION_JSON) + .header("content-length", "4096"); if let Some(etag) = etag { builder = builder.header("etag", etag); } @@ -787,13 +788,17 @@ mod tests { .expect("client"); let expected = StrongEtag::parse(ETAG).expect("etag"); + let response = not_modified_response(Some(ETAG), b""); + assert_eq!( + response + .headers() + .get(reqwest::header::CONTENT_LENGTH) + .expect("content length"), + "4096" + ); assert!(matches!( client - .wire( - not_modified_response(Some(ETAG), b""), - Some(APPLICATION_JSON), - Some(&expected) - ) + .wire(response, Some(APPLICATION_JSON), Some(&expected)) .await, Ok(WireOutcome::NotModified(_)) )); @@ -816,7 +821,7 @@ mod tests { let trace = registry_platform_httpsec::TraceId::parse("4bf92f3577b34da6a3ce929d0e0e4736") .expect("trace ID"); assert!(matches!( - not_modified_outcome(expected, trace, b"must-not-be-present"), + not_modified_outcome(expected, trace, false), Err(RelayClientError::Protocol { .. }) )); } diff --git a/crates/registry-relay-client/src/query.rs b/crates/registry-relay-client/src/query.rs index 6cdfa2023..38a1dad69 100644 --- a/crates/registry-relay-client/src/query.rs +++ b/crates/registry-relay-client/src/query.rs @@ -155,17 +155,43 @@ impl BoundingBox { } #[derive(Clone, Debug, Default, PartialEq)] -pub struct CollectionRequest { - pub(crate) options: RecordOptions, - pub(crate) page_size: Option, - pub(crate) filters: BTreeMap, - pub(crate) bbox: Option, +struct CollectionOptions { + options: RecordOptions, + page_size: Option, } -impl CollectionRequest { +impl CollectionOptions { + fn pairs(&self) -> Vec<(String, String)> { + let mut pairs = Vec::new(); + if let Some(value) = self.page_size { + pairs.push(("pageSize".into(), value.to_string())); + } + self.options.append_query(&mut pairs); + pairs + } +} + +/// First-page facts accepted by a Relay List operation. +/// +/// List operations may carry declared equality filters but never a spatial +/// bounding box. Explicit continuations use [`crate::CollectionContinuation`] +/// instead of this type. +/// +/// ```compile_fail +/// use registry_relay_client::{BoundingBox, ListRequest}; +/// let bbox = BoundingBox::new(100.0, 13.0, 101.0, 14.0).unwrap(); +/// let _ = ListRequest::default().bbox(bbox); +/// ``` +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ListRequest { + common: CollectionOptions, + filters: BTreeMap, +} + +impl ListRequest { #[must_use] pub fn options(mut self, options: RecordOptions) -> Self { - self.options = options; + self.common.options = options; self } @@ -175,7 +201,7 @@ impl CollectionRequest { "page size must be greater than zero", )); } - self.page_size = Some(value); + self.common.page_size = Some(value); Ok(self) } @@ -201,21 +227,8 @@ impl CollectionRequest { Ok(self) } - #[must_use] - pub fn bbox(mut self, value: BoundingBox) -> Self { - self.bbox = Some(value); - self - } - pub(crate) fn pairs(&self) -> Result, RelayClientError> { - let mut pairs = Vec::new(); - if let Some(value) = self.page_size { - pairs.push(("pageSize".into(), value.to_string())); - } - self.options.append_query(&mut pairs); - if let Some(value) = self.bbox { - pairs.push(("bbox".into(), value.text())); - } + let mut pairs = self.common.pairs(); pairs.extend( self.filters .iter() @@ -224,6 +237,69 @@ impl CollectionRequest { ensure_query_bound(&pairs)?; Ok(pairs) } + + pub(crate) fn record_options(&self) -> RecordOptions { + self.common.options.clone() + } +} + +/// First-page facts accepted by a named Relay point-bbox Search operation. +/// +/// The closed Relay V2 search profile always requires one bbox and accepts no +/// caller-defined equality filters. Explicit continuations use +/// [`crate::CollectionContinuation`] instead of this type. +/// +/// ```compile_fail +/// use registry_relay_client::SearchRequest; +/// let _ = SearchRequest::default(); +/// ``` +/// +/// ```compile_fail +/// use registry_relay_client::{BoundingBox, SearchRequest}; +/// let bbox = BoundingBox::new(100.0, 13.0, 101.0, 14.0).unwrap(); +/// let _ = SearchRequest::new(bbox).filter("status", "active"); +/// ``` +#[derive(Clone, Debug, PartialEq)] +pub struct SearchRequest { + common: CollectionOptions, + bbox: BoundingBox, +} + +impl SearchRequest { + #[must_use] + pub fn new(bbox: BoundingBox) -> Self { + Self { + common: CollectionOptions::default(), + bbox, + } + } + + #[must_use] + pub fn options(mut self, options: RecordOptions) -> Self { + self.common.options = options; + self + } + + pub fn page_size(mut self, value: u32) -> Result { + if value == 0 { + return Err(RelayClientError::invalid_request( + "page size must be greater than zero", + )); + } + self.common.page_size = Some(value); + Ok(self) + } + + pub(crate) fn pairs(&self) -> Result, RelayClientError> { + let mut pairs = self.common.pairs(); + pairs.push(("bbox".into(), self.bbox.text())); + ensure_query_bound(&pairs)?; + Ok(pairs) + } + + pub(crate) fn record_options(&self) -> RecordOptions { + self.common.options.clone() + } } #[derive(Clone, Debug, Default, PartialEq)] @@ -644,12 +720,28 @@ mod tests { #[test] fn first_page_filters_cannot_claim_reserved_parameters() { - let error = CollectionRequest::default() + let error = ListRequest::default() .filter("cursor", "opaque") .unwrap_err(); assert!(matches!(error, RelayClientError::InvalidRequest { .. })); } + #[test] + fn list_and_search_first_page_shapes_match_the_closed_route_contract() { + let list = ListRequest::default() + .filter("status", "active") + .expect("list filter"); + let list_pairs = list.pairs().expect("list pairs"); + assert!(list_pairs.iter().any(|(name, _)| name == "status")); + assert!(list_pairs.iter().all(|(name, _)| name != "bbox")); + + let search = SearchRequest::new(BoundingBox::new(100.0, 13.0, 101.0, 14.0).expect("bbox")); + assert_eq!( + search.pairs().expect("search pairs"), + vec![("bbox".into(), "100,13,101,14".into())] + ); + } + #[test] fn request_builders_reject_ambiguous_or_oversized_first_page_facts() { assert!(ResourceListRequest::default().page_size(101).is_err()); diff --git a/crates/registry-relay-client/src/transport.rs b/crates/registry-relay-client/src/transport.rs index 089f272ba..2ecf9c1c5 100644 --- a/crates/registry-relay-client/src/transport.rs +++ b/crates/registry-relay-client/src/transport.rs @@ -73,6 +73,24 @@ impl Transport { .await .map_err(|error| RelayClientError::transport(read_failure_kind(&error))) } + + /// Inspect the actual 304 message body without treating Content-Length as + /// its size. RFC 9110 permits Content-Length on a 304 to describe the + /// selected representation that a 200 response would have carried. + pub(crate) async fn not_modified_body_is_empty( + &self, + mut response: Response, + ) -> Result { + while let Some(chunk) = response.chunk().await.map_err(|error| { + let error = registry_platform_httputil::BoundedReadError::Transport(error); + RelayClientError::transport(read_failure_kind(&error)) + })? { + if !chunk.is_empty() { + return Ok(false); + } + } + Ok(true) + } } pub(crate) fn trace_id( diff --git a/crates/registry-relay-client/tests/http_boundary.rs b/crates/registry-relay-client/tests/http_boundary.rs index e4c732157..e3912acdd 100644 --- a/crates/registry-relay-client/tests/http_boundary.rs +++ b/crates/registry-relay-client/tests/http_boundary.rs @@ -9,10 +9,11 @@ use axum::routing::any; use axum::Router; use registry_platform_httputil::client::{BearerToken, TokenError, TokenProvider}; use registry_relay_client::{ - CollectionContinuation, CollectionContinuationProjection, CollectionRequest, - CollectionRouteProjection, Conditional, LookupRequest, ProblemCode, RecordFormat, + BoundingBox, CollectionContinuation, CollectionContinuationProjection, + CollectionRouteProjection, Conditional, ListRequest, LookupRequest, ProblemCode, RecordFormat, RecordOptions, RelayClient, RelayClientConfig, RelayClientError, ResourceContinuation, - ResourceListRequest, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, StrongEtag, + ResourceListRequest, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, SearchRequest, + StrongEtag, }; use serde_json::json; use tokio::net::TcpListener; @@ -146,6 +147,10 @@ async fn handler(State(state): State, request: Request) -> Resp response .headers_mut() .insert("etag", ETAG.parse().expect("etag")); + response.headers_mut().insert( + "content-length", + "4096".parse().expect("representation content length"), + ); response } Mode::NotModifiedMissingEtag => wire_response( @@ -382,10 +387,12 @@ async fn base_prefix_is_preserved_for_every_route_family() { let _ = client.resources(ResourceListRequest::default(), None).await; let _ = client.resource("people", None).await; let _ = client - .list_records("people", &CollectionRequest::default(), None) + .list_records("people", &ListRequest::default(), None) .await; + let search_request = + SearchRequest::new(BoundingBox::new(100.0, 13.0, 101.0, 14.0).expect("search bbox")); let _ = client - .search_records("people", "by-name", &CollectionRequest::default(), None) + .search_records("people", "by-name", &search_request, None) .await; let _ = client .read_record("people", "person-1", &RecordOptions::default(), None) diff --git a/crates/registry-relay-v2/tests/acceptance_http.rs b/crates/registry-relay-v2/tests/acceptance_http.rs index 9654c3fb9..c4cf51ad0 100644 --- a/crates/registry-relay-v2/tests/acceptance_http.rs +++ b/crates/registry-relay-v2/tests/acceptance_http.rs @@ -28,9 +28,9 @@ use registry_platform_testing::{ fixtures, oidc_verifier_config, sign_ed25519_compact_jwt, MockIdp, }; use registry_relay_client::{ - BoundingBox, CollectionRequest, Conditional, LookupRequest, RecordCollectionResponse, - RecordFormat, RecordOptions, RecordResponse, RelayClient, RelayClientConfig, - ResourceListRequest, SdmxDataFormat, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, + BoundingBox, Conditional, ListRequest, LookupRequest, RecordCollectionResponse, RecordFormat, + RecordOptions, RecordResponse, RelayClient, RelayClientConfig, ResourceListRequest, + SdmxDataFormat, SdmxDataRequest, SdmxStructureKind, SdmxStructureRequest, SearchRequest, StaticToken, TokenProvider, }; use registry_relay_v2::artifacts::generate_artifacts; @@ -446,7 +446,7 @@ async fn rust_client_drives_the_real_relay_router_across_the_public_surface() { "registered-business" ); - let first_list_request = CollectionRequest::default() + let first_list_request = ListRequest::default() .page_size(1) .expect("record page size is valid") .filter("jurisdiction", "EX-A") @@ -557,9 +557,10 @@ async fn rust_client_drives_the_real_relay_router_across_the_public_surface() { RecordResponse::Json(_) => panic!("GeoJSON feature read returned ordinary JSON"), } - let spatial_request = CollectionRequest::default() - .options(RecordOptions::default().format(RecordFormat::GeoJsonRfc7946)) - .bbox(BoundingBox::new(100.0, 13.0, 101.0, 14.0).expect("fixture bbox is valid")); + let spatial_request = SearchRequest::new( + BoundingBox::new(100.0, 13.0, 101.0, 14.0).expect("fixture bbox is valid"), + ) + .options(RecordOptions::default().format(RecordFormat::GeoJsonRfc7946)); let spatial = complete( client .search_records("registered-premises", "within-bbox", &spatial_request, None) @@ -595,9 +596,10 @@ async fn rust_client_drives_the_real_relay_router_across_the_public_surface() { panic!("GeoJSON search continuation returned ordinary JSON") } } - let json_fg_request = CollectionRequest::default() - .options(RecordOptions::default().format(RecordFormat::JsonFg)) - .bbox(BoundingBox::new(100.0, 13.0, 101.0, 14.0).expect("fixture bbox is valid")); + let json_fg_request = SearchRequest::new( + BoundingBox::new(100.0, 13.0, 101.0, 14.0).expect("fixture bbox is valid"), + ) + .options(RecordOptions::default().format(RecordFormat::JsonFg)); let json_fg = complete( client .search_records("registered-premises", "within-bbox", &json_fg_request, None) diff --git a/docs/site/src/content/docs/reference/relay-client-api.mdx b/docs/site/src/content/docs/reference/relay-client-api.mdx index 7fac5b625..f7a47b99b 100644 --- a/docs/site/src/content/docs/reference/relay-client-api.mdx +++ b/docs/site/src/content/docs/reference/relay-client-api.mdx @@ -175,7 +175,8 @@ facts in an options object and uses camel case, such as `pageSize`, `accessProfi | Resource list | None | Page size from 1 through 100; ETag | `ResourceListRequest` | | Resource detail | Resource identifier | ETag | Resource identifier as `&str` | | Resource continuation | Complete resource continuation | ETag | `ResourceContinuation` | -| Record list or search | Resource identifier; search identifier for search | Page size, record options, filters, bbox, ETag | `CollectionRequest` | +| Record list | Resource identifier | Page size, record options, filters, ETag | `ListRequest` | +| Record search | Resource identifier, search identifier, bbox | Page size, record options, ETag | `SearchRequest` | | Record or search continuation | Complete matching continuation | ETag | `CollectionContinuation` | | Record read | Resource and record identifiers | Record options; ETag | `RecordOptions` | | Governed lookup | Resource and lookup identifiers; selectors | Record options; ETag | `LookupRequest` | @@ -183,11 +184,11 @@ facts in an options object and uses camel case, such as `pageSize`, `accessProfi | SDMX data | Agency, resource, three-part version | Key, constraints, offset, limit, dimension at observation, format, ETag | `SdmxDataRequest` | | SDMX structure | Kind, agency, resource, three-part version | ETag | `SdmxStructureRequest` | -`RecordOptions` contains fields, access profile, and record format. `CollectionRequest` adds a -positive page size, a string-to-string filter mapping, and an optional bounding box ordered as -`[west, south, east, north]`. Rust constructs those with `RecordOptions`, `CollectionRequest`, and -`BoundingBox`. Python passes the same facts as keyword arguments. Node passes them in -`RecordOptions` or `CollectionOptions` objects. +`RecordOptions` contains fields, access profile, and record format. `ListRequest` adds a positive +page size and a string-to-string filter mapping. `SearchRequest::new` requires a `BoundingBox` +ordered as `[west, south, east, north]` and can add a positive page size. Python passes list +filters to `list_records` and requires the bbox keyword on `search`. Node uses `ListOptions` for +`listRecords` and `SearchOptions`, whose `bbox` member is required, for `search`. Input record formats are `json`, `json-ld`, `geojson`, and `json-fg` in Python. Node also accepts `geo-json-rfc7946` as an alias for `geojson`. Rust uses `RecordFormat::Json`, `JsonLd`, From ed81487de2d9b5ee39f455e67734128495991beb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 21:51:32 +0700 Subject: [PATCH 8/9] fix(relay-client): harden public argument validation Signed-off-by: Jeremi Joslin --- .../__test__/binding.test.js | 44 +++++++++++++ crates/registry-relay-client-node/client.d.ts | 1 + crates/registry-relay-client-node/src/lib.rs | 62 ++++++++++++++++++- crates/registry-relay-client-py/src/lib.rs | 59 ++++++++++++++++-- .../tests/python/test_construction.py | 13 ++++ .../tests/python/test_request_shapes.py | 41 ++++++++++++ crates/registry-relay-client/README.md | 3 + crates/registry-relay-client/src/query.rs | 45 +++++++++++++- crates/registry-relay-client/src/response.rs | 3 +- .../tests/http_boundary.rs | 27 ++++++++ 10 files changed, 289 insertions(+), 9 deletions(-) diff --git a/crates/registry-relay-client-node/__test__/binding.test.js b/crates/registry-relay-client-node/__test__/binding.test.js index e0fb642b8..9539f0aa8 100644 --- a/crates/registry-relay-client-node/__test__/binding.test.js +++ b/crates/registry-relay-client-node/__test__/binding.test.js @@ -26,11 +26,24 @@ function assertBoundaryChildExitsNormally(source) { let server; let baseUrl; +const lookupBodies = []; before(async () => { server = http.createServer((request, response) => { response.setHeader('traceparent', TRACEPARENT); response.setHeader('content-type', 'application/json'); + if (request.method === 'POST' + && request.url.includes('/lookups/') + && request.headers['if-none-match'] !== ETAG) { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.on('end', () => { + lookupBodies.push(Buffer.concat(chunks).toString('utf8')); + response.statusCode = 404; + response.end('{}'); + }); + return; + } if (request.headers['if-none-match'] === ETAG) { response.statusCode = 304; response.setHeader('etag', ETAG); @@ -154,6 +167,37 @@ test('request validation failures have a distinct stable kind', async () => { ); }); +test('lookup preserves the full JavaScript safe integer domain in its JSON body', async () => { + lookupBodies.length = 0; + const client = new RelayClient({ baseUrl }); + await assert.rejects(client.lookup('people', 'by-number', { + max: Number.MAX_SAFE_INTEGER, + min: Number.MIN_SAFE_INTEGER, + wide: 2 ** 32, + })); + assert.deepEqual(lookupBodies, [ + '{"selectors":{"max":9007199254740991,"min":-9007199254740991,"wide":4294967296}}', + ]); +}); + +test('lookup rejects fractional and unsafe numeric selectors as invalid requests', async () => { + lookupBodies.length = 0; + const client = new RelayClient({ baseUrl }); + for (const selector of [ + 1.5, + Number.MAX_SAFE_INTEGER + 1, + Number.MIN_SAFE_INTEGER - 1, + ]) { + await assert.rejects( + client.lookup('people', 'by-number', { number: selector }), + (error) => error instanceof RelayClientError + && error.kind === 'invalid_request' + && error.message === 'a lookup selector value is invalid', + ); + } + assert.deepEqual(lookupBodies, []); +}); + test('list and search runtime options preserve their distinct query shapes', async () => { const client = new RelayClient({ baseUrl }); for (const promise of [ diff --git a/crates/registry-relay-client-node/client.d.ts b/crates/registry-relay-client-node/client.d.ts index bbdfc17a8..dedcfc0a9 100644 --- a/crates/registry-relay-client-node/client.d.ts +++ b/crates/registry-relay-client-node/client.d.ts @@ -62,6 +62,7 @@ export interface SearchOptions extends RecordOptions { bbox: readonly [number, number, number, number] } +/** Numeric selectors must satisfy `Number.isSafeInteger`. */ export type LookupSelector = string | number | boolean export type LookupSelectors = Readonly> diff --git a/crates/registry-relay-client-node/src/lib.rs b/crates/registry-relay-client-node/src/lib.rs index 8da21b400..015af4c84 100644 --- a/crates/registry-relay-client-node/src/lib.rs +++ b/crates/registry-relay-client-node/src/lib.rs @@ -742,6 +742,37 @@ fn record_options_request(value: Option) -> Result { record_options(&object) } +fn lookup_selector_value(value: &Value) -> Result { + const MAXIMUM_SAFE_INTEGER_I64: i64 = 9_007_199_254_740_991; + const MAXIMUM_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; + + let Value::Number(number) = value else { + return Ok(value.clone()); + }; + if let Some(number) = number.as_i64() { + if (-MAXIMUM_SAFE_INTEGER_I64..=MAXIMUM_SAFE_INTEGER_I64).contains(&number) { + return Ok(value.clone()); + } + return Err(binding_error( + "invalid_request", + "a lookup selector value is invalid", + )); + } + let number = number + .as_f64() + .ok_or_else(|| binding_error("invalid_request", "a lookup selector value is invalid"))?; + if !number.is_finite() + || number.fract() != 0.0 + || !(-MAXIMUM_SAFE_INTEGER..=MAXIMUM_SAFE_INTEGER).contains(&number) + { + return Err(binding_error( + "invalid_request", + "a lookup selector value is invalid", + )); + } + Ok(Value::from(number as i64)) +} + fn collection_continuation(value: Value, expected: &'static str) -> Result { let object = value .as_object() @@ -956,7 +987,7 @@ impl RelayClient { let mut request = LookupRequest::default().options(record_options_request(options)?); for (name, value) in selectors { request = request - .selector(name, value.clone()) + .selector(name, lookup_selector_value(value)?) .map_err(client_error)?; } let etag = parse_etag(etag)?; @@ -1232,6 +1263,35 @@ mod tests { .expect("the closed search query shape is accepted"); } + #[test] + fn javascript_safe_integer_selectors_become_signed_json_integers() { + for (wire, expected) in [ + ("4294967296.0", 4_294_967_296_i64), + ("9007199254740991.0", 9_007_199_254_740_991_i64), + ("-9007199254740991.0", -9_007_199_254_740_991_i64), + ] { + let value = serde_json::from_str(wire).expect("a floating JSON number"); + let normalized = lookup_selector_value(&value).expect("a safe integer"); + assert_eq!(normalized.as_i64(), Some(expected)); + assert!(normalized + .as_number() + .is_some_and(serde_json::Number::is_i64)); + } + + for wire in [ + "1.5", + "9007199254740992", + "9007199254740992.0", + "-9007199254740992.0", + ] { + let value = serde_json::from_str(wire).expect("a floating JSON number"); + let error = lookup_selector_value(&value).unwrap_err(); + let envelope = error_envelope(error); + assert_eq!(envelope["kind"], "invalid_request"); + assert_eq!(envelope["message"], "a lookup selector value is invalid"); + } + } + #[test] fn continuation_route_must_match_its_consumer() { let error = collection_continuation( diff --git a/crates/registry-relay-client-py/src/lib.rs b/crates/registry-relay-client-py/src/lib.rs index 2f75d9b6d..dc8df4f10 100644 --- a/crates/registry-relay-client-py/src/lib.rs +++ b/crates/registry-relay-client-py/src/lib.rs @@ -77,6 +77,46 @@ fn parse_etag(py: Python<'_>, value: Option<&str>) -> PyResult, + value: Option<&Bound<'_, PyAny>>, + name: &'static str, +) -> PyResult> { + value + .map(|value| { + value.extract::().map_err(|_| { + to_py_err( + py, + &MappedError::binding( + "invalid_request", + format!("{name} must be an unsigned 32-bit integer"), + ), + ) + }) + }) + .transpose() +} + +fn optional_u64_configuration( + py: Python<'_>, + value: Option<&Bound<'_, PyAny>>, + name: &'static str, +) -> PyResult> { + value + .map(|value| { + value.extract::().map_err(|_| { + to_py_err( + py, + &MappedError::binding( + "configuration", + format!("{name} must be an unsigned 64-bit integer"), + ), + ) + }) + }) + .transpose() +} + fn record_format(py: Python<'_>, value: &str) -> PyResult { match value { "json" => Ok(RecordFormat::Json), @@ -368,9 +408,11 @@ impl RelayClient { request_timeout_seconds: Option, connect_timeout_seconds: Option, user_agent: Option, - max_response_bytes: Option, + max_response_bytes: Option<&Bound<'_, PyAny>>, trusted_root_certificates: Option>, ) -> PyResult { + let max_response_bytes = + optional_u64_configuration(py, max_response_bytes, "max_response_bytes")?; let (authorization, private_key_jwt_trusted_root_certificates) = authorization_from_python(authorization) .map_err(|error| conversion_error(py, "configuration", &error))?; @@ -440,9 +482,10 @@ impl RelayClient { fn resources<'py>( &self, py: Python<'py>, - page_size: Option, + page_size: Option<&Bound<'_, PyAny>>, etag: Option<&str>, ) -> PyResult> { + let page_size = optional_u32(py, page_size, "page_size")?; let mut request = ResourceListRequest::default(); if let Some(page_size) = page_size { request = request @@ -516,13 +559,14 @@ impl RelayClient { &self, py: Python<'py>, resource: &str, - page_size: Option, + page_size: Option<&Bound<'_, PyAny>>, fields: Option>, access_profile: Option, format: &str, filters: Option<&Bound<'_, PyAny>>, etag: Option<&str>, ) -> PyResult> { + let page_size = optional_u32(py, page_size, "page_size")?; let request = list_request(py, page_size, fields, access_profile, format, filters)?; let etag = parse_etag(py, etag)?; let value = py @@ -555,12 +599,13 @@ impl RelayClient { resource: &str, search: &str, bbox: &Bound<'_, PyAny>, - page_size: Option, + page_size: Option<&Bound<'_, PyAny>>, fields: Option>, access_profile: Option, format: &str, etag: Option<&str>, ) -> PyResult> { + let page_size = optional_u32(py, page_size, "page_size")?; let request = search_request(py, bbox, page_size, fields, access_profile, format)?; let etag = parse_etag(py, etag)?; let value = py @@ -675,12 +720,14 @@ impl RelayClient { version: &str, key: Option, constraints: Option<&Bound<'_, PyAny>>, - offset: Option, - limit: Option, + offset: Option<&Bound<'_, PyAny>>, + limit: Option<&Bound<'_, PyAny>>, dimension_at_observation: Option, format: &str, etag: Option<&str>, ) -> PyResult> { + let offset = optional_u32(py, offset, "offset")?; + let limit = optional_u32(py, limit, "limit")?; let mut request = SdmxDataRequest::new(agency, resource, version) .map_err(|error| sdk_error(py, &error))?; if let Some(key) = key { diff --git a/crates/registry-relay-client-py/tests/python/test_construction.py b/crates/registry-relay-client-py/tests/python/test_construction.py index d03070860..7bc98d86e 100644 --- a/crates/registry-relay-client-py/tests/python/test_construction.py +++ b/crates/registry-relay-client-py/tests/python/test_construction.py @@ -172,6 +172,19 @@ def test_unsafe_base_url_and_zero_bounds_are_rejected_by_the_sdk(self): relay.RelayClient(**kwargs) self.assertEqual(raised.exception.kind, "configuration") + def test_max_response_bytes_range_errors_are_stable_configuration_errors(self): + for value in (-1, 1 << 100): + with self.subTest(value=value): + with self.assertRaises(relay.RelayClientError) as raised: + relay.RelayClient( + "http://127.0.0.1:9", max_response_bytes=value + ) + self.assertEqual(raised.exception.kind, "configuration") + self.assertEqual( + str(raised.exception), + "max_response_bytes must be an unsigned 64-bit integer", + ) + if __name__ == "__main__": unittest.main() diff --git a/crates/registry-relay-client-py/tests/python/test_request_shapes.py b/crates/registry-relay-client-py/tests/python/test_request_shapes.py index 3f204ca22..5d92b6347 100644 --- a/crates/registry-relay-client-py/tests/python/test_request_shapes.py +++ b/crates/registry-relay-client-py/tests/python/test_request_shapes.py @@ -60,6 +60,47 @@ def test_search_rejects_invalid_bbox_without_exposing_values(self): self.client.search("people", "nearby", bbox=cyclic) self.assertEqual(cycle.exception.kind, "invalid_request") + def test_unsigned_request_ranges_are_stable_invalid_request_errors(self): + calls = ( + ("resources", lambda value: self.client.resources(page_size=value), "page_size"), + ( + "list", + lambda value: self.client.list_records("people", page_size=value), + "page_size", + ), + ( + "search", + lambda value: self.client.search( + "people", "nearby", bbox=[10, 20, 11, 21], page_size=value + ), + "page_size", + ), + ( + "sdmx offset", + lambda value: self.client.sdmx_data( + "AGENCY", "FLOW", "1.0.0", offset=value + ), + "offset", + ), + ( + "sdmx limit", + lambda value: self.client.sdmx_data( + "AGENCY", "FLOW", "1.0.0", limit=value + ), + "limit", + ), + ) + for value in (-1, 1 << 100): + for family, call, name in calls: + with self.subTest(family=family, value=value): + with self.assertRaises(relay.RelayClientError) as raised: + call(value) + self.assertEqual(raised.exception.kind, "invalid_request") + self.assertEqual( + str(raised.exception), + f"{name} must be an unsigned 32-bit integer", + ) + if __name__ == "__main__": unittest.main() diff --git a/crates/registry-relay-client/README.md b/crates/registry-relay-client/README.md index 5e490d425..5f815b6e1 100644 --- a/crates/registry-relay-client/README.md +++ b/crates/registry-relay-client/README.md @@ -38,6 +38,9 @@ auth-eligible request. `health`, `ready`, and `openapi` never call it and never send authorization. `PrivateKeyJwt` and `StaticToken` are re-exported from the shared platform client primitives. +Explicit access-profile selectors use Relay's bounded lower-case kebab grammar: +ASCII lower-case letters, digits, and single interior hyphens, up to 128 bytes. + ## Discovery and consultation ```rust,no_run diff --git a/crates/registry-relay-client/src/query.rs b/crates/registry-relay-client/src/query.rs index 38a1dad69..beb0dad88 100644 --- a/crates/registry-relay-client/src/query.rs +++ b/crates/registry-relay-client/src/query.rs @@ -93,7 +93,7 @@ impl RecordOptions { pub fn access_profile(mut self, value: impl Into) -> Result { let value = value.into(); - validate_identifier(&value, "the access profile identifier is invalid")?; + validate_access_profile_identifier(&value)?; self.access_profile = Some(value); Ok(self) } @@ -578,6 +578,23 @@ fn validate_identifier(value: &str, reason: &'static str) -> Result<(), RelayCli Ok(()) } +pub(crate) fn validate_access_profile_identifier(value: &str) -> Result<(), RelayClientError> { + if value.is_empty() + || value.len() > MAX_IDENTIFIER_BYTES + || value.starts_with('-') + || value.ends_with('-') + || value.contains("--") + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(RelayClientError::invalid_request( + "the access profile identifier is invalid", + )); + } + Ok(()) +} + fn valid_sdmx_ncname_segment(value: &str) -> bool { let mut bytes = value.bytes(); matches!(bytes.next(), Some(first) if first.is_ascii_alphabetic()) @@ -726,6 +743,32 @@ mod tests { assert!(matches!(error, RelayClientError::InvalidRequest { .. })); } + #[test] + fn access_profile_identifiers_match_the_server_lowercase_kebab_grammar() { + for valid in ["public", "registrar-premises", "1", &"a".repeat(128)] { + assert!( + RecordOptions::default().access_profile(valid).is_ok(), + "rejected {valid:?}" + ); + } + for invalid in [ + "", + "Public", + "public_profile", + "-public", + "public-", + "public--profile", + "public.profile", + "público", + &"a".repeat(129), + ] { + assert!( + RecordOptions::default().access_profile(invalid).is_err(), + "accepted {invalid:?}" + ); + } + } + #[test] fn list_and_search_first_page_shapes_match_the_closed_route_contract() { let list = ListRequest::default() diff --git a/crates/registry-relay-client/src/response.rs b/crates/registry-relay-client/src/response.rs index 653c22ce6..4bb8cf18e 100644 --- a/crates/registry-relay-client/src/response.rs +++ b/crates/registry-relay-client/src/response.rs @@ -3,6 +3,7 @@ use std::fmt; use registry_platform_httpsec::TraceId; use serde::{Deserialize, Serialize}; +use crate::query::validate_access_profile_identifier; use crate::RecordFormat; /// A validated strong entity tag over SHA-256 bytes (`"` plus 64 lower hex digits plus `"`). @@ -181,7 +182,7 @@ impl CollectionContinuation { } }; if let Some(profile) = &value.access_profile { - validate_route_identifier(profile)?; + validate_access_profile_identifier(profile)?; } Ok(Self { cursor: value.cursor, diff --git a/crates/registry-relay-client/tests/http_boundary.rs b/crates/registry-relay-client/tests/http_boundary.rs index e3912acdd..4707119d1 100644 --- a/crates/registry-relay-client/tests/http_boundary.rs +++ b/crates/registry-relay-client/tests/http_boundary.rs @@ -377,6 +377,33 @@ async fn probes_and_openapi_never_acquire_a_token() { assert_eq!(provider.0.load(Ordering::SeqCst), 0); } +#[tokio::test] +async fn invalid_access_profiles_are_rejected_before_auth_or_io() { + let provider = Arc::new(CountingToken(AtomicUsize::new(0))); + let (_client, paths) = test_client(Mode::Routes, Some(provider.clone())).await; + + for invalid in ["Public", "public_profile", "public--profile"] { + assert!(matches!( + RecordOptions::default().access_profile(invalid), + Err(RelayClientError::InvalidRequest { .. }) + )); + } + assert!( + CollectionContinuation::try_from_projection(CollectionContinuationProjection { + route: CollectionRouteProjection::Records { + resource: "people".into(), + }, + cursor: "opaque_cursor-123".into(), + format: RecordFormat::Json, + access_profile: Some("Public_Profile".into()), + }) + .is_err() + ); + + assert_eq!(provider.0.load(Ordering::SeqCst), 0); + assert!(paths.lock().expect("paths").is_empty()); +} + #[tokio::test] async fn base_prefix_is_preserved_for_every_route_family() { let (client, paths) = test_client(Mode::Routes, None).await; From 976a0fa72a4083808601372a94fc1425902e158e Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Tue, 11 Aug 2026 22:30:50 +0700 Subject: [PATCH 9/9] fix(relay-client): close request validation gaps Signed-off-by: Jeremi Joslin --- crates/registry-relay-client-node/README.md | 4 + .../__test__/binding.test.js | 146 ++++++++++++++++-- crates/registry-relay-client-node/client.d.ts | 29 ++-- crates/registry-relay-client-node/src/lib.rs | 146 +++++++++++++----- crates/registry-relay-client-py/README.md | 5 +- .../python/registry_relay_client/__init__.pyi | 3 +- .../tests/python/test_drift.py | 40 ++++- .../tests/python/test_request_shapes.py | 48 ++++++ crates/registry-relay-client/src/client.rs | 8 +- crates/registry-relay-client/src/query.rs | 63 +++++++- 10 files changed, 411 insertions(+), 81 deletions(-) diff --git a/crates/registry-relay-client-node/README.md b/crates/registry-relay-client-node/README.md index 6ea8cc7bd..7f78d4d06 100644 --- a/crates/registry-relay-client-node/README.md +++ b/crates/registry-relay-client-node/README.md @@ -46,6 +46,10 @@ One aggregate budget per call permits at most 128 levels, 100,000 values, and 4 MiB of UTF-8 string data. These checks prevent recursive native conversion of cyclic or active JavaScript objects. +Integer-valued configuration and request options must satisfy +`Number.isSafeInteger` and the bound of their target option. Safe integers are +preserved exactly across native conversion. + Raw OpenAPI, artifact, and SDMX responses carry `body` as a Node `Buffer` and `mediaType` as the accepted server media type. diff --git a/crates/registry-relay-client-node/__test__/binding.test.js b/crates/registry-relay-client-node/__test__/binding.test.js index 9539f0aa8..b92a6d5b1 100644 --- a/crates/registry-relay-client-node/__test__/binding.test.js +++ b/crates/registry-relay-client-node/__test__/binding.test.js @@ -27,9 +27,11 @@ function assertBoundaryChildExitsNormally(source) { let server; let baseUrl; const lookupBodies = []; +const requestUrls = []; before(async () => { server = http.createServer((request, response) => { + requestUrls.push(request.url); response.setHeader('traceparent', TRACEPARENT); response.setHeader('content-type', 'application/json'); if (request.method === 'POST' @@ -167,6 +169,91 @@ test('request validation failures have a distinct stable kind', async () => { ); }); +test('constructor numeric options preserve JavaScript safe integers', () => { + for (const numericOptions of [ + { maxResponseBytes: 2 ** 32 }, + { maxResponseBytes: Number.MAX_SAFE_INTEGER }, + { requestTimeoutMilliseconds: 2 ** 32 }, + { connectTimeoutMilliseconds: 2 ** 32 }, + ]) { + assert.ok(new RelayClient({ baseUrl, ...numericOptions })); + } + + for (const maxResponseBytes of [1.5, -1, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws( + () => new RelayClient({ baseUrl, maxResponseBytes }), + (error) => error instanceof RelayClientError + && error.kind === 'configuration' + && error.message === 'maxResponseBytes must be a non-negative integer', + ); + } + assert.throws( + () => new RelayClient({ baseUrl, maxResponseBytes: 0 }), + (error) => error instanceof RelayClientError + && error.kind === 'configuration' + && error.message === 'the response body bound must be greater than zero', + ); +}); + +test('request integer options accept their target maxima without precision loss', async () => { + requestUrls.length = 0; + const client = new RelayClient({ baseUrl }); + await assert.rejects(client.resources({ pageSize: 100 })); + await assert.rejects(client.listRecords('people', { pageSize: 0xffff_ffff })); + await assert.rejects(client.search('people', 'within-bbox', { + pageSize: 0xffff_ffff, + bbox: [-10, -5, 10, 5], + })); + await assert.rejects(client.sdmxData({ + agency: 'AGENCY', + resource: 'FLOW', + version: '1.0.0', + offset: 0xffff_ffff, + limit: 0xffff_ffff, + })); + + assert.equal(requestUrls.length, 4); + const queries = requestUrls.map((value) => new URL(value, baseUrl).searchParams); + assert.equal(queries[0].get('pageSize'), '100'); + assert.equal(queries[1].get('pageSize'), '4294967295'); + assert.equal(queries[2].get('pageSize'), '4294967295'); + assert.equal(queries[3].get('offset'), '4294967295'); + assert.equal(queries[3].get('limit'), '4294967295'); +}); + +test('request integer options reject fractional, unsafe, and target-overflow values before I/O', async () => { + requestUrls.length = 0; + const client = new RelayClient({ baseUrl }); + for (const invoke of [ + () => client.resources({ pageSize: 1.5 }), + () => client.listRecords('people', { pageSize: Number.MAX_SAFE_INTEGER + 1 }), + () => client.search('people', 'within-bbox', { + pageSize: 2 ** 32, + bbox: [-10, -5, 10, 5], + }), + () => client.sdmxData({ + agency: 'AGENCY', resource: 'FLOW', version: '1.0.0', offset: 2 ** 32, + }), + () => client.sdmxData({ + agency: 'AGENCY', resource: 'FLOW', version: '1.0.0', limit: -1, + }), + ]) { + await assert.rejects( + invoke(), + (error) => error instanceof RelayClientError + && error.kind === 'invalid_request' + && /must be a non-negative integer/.test(error.message), + ); + } + await assert.rejects( + client.resources({ pageSize: 101 }), + (error) => error instanceof RelayClientError + && error.kind === 'invalid_request' + && error.message === 'resource page size must be between 1 and 100', + ); + assert.deepEqual(requestUrls, []); +}); + test('lookup preserves the full JavaScript safe integer domain in its JSON body', async () => { lookupBodies.length = 0; const client = new RelayClient({ baseUrl }); @@ -261,9 +348,11 @@ test('synchronous napi argument conversion failures use fixed redacted envelopes for (const invoke of [ () => client.resource(42), () => client.resources({ pageSize: 1n }), + () => client.resources({ pageSize: Number.POSITIVE_INFINITY }), () => client.lookup('people', 'by-identity', undefined), () => client.lookup('people', 'by-identity', { number: Number.NaN }), () => client.lookup('people', 'by-identity', { number: Number.POSITIVE_INFINITY }), + () => client.search('people', 'within-bbox', { bbox: [Number.NaN, -5, 10, 5] }), ]) { assert.throws( invoke, @@ -275,7 +364,12 @@ test('synchronous napi argument conversion failures use fixed redacted envelopes }); test('constructor napi conversion failures use fixed configuration envelopes', () => { - for (const config of [{ baseUrl: 1n }, undefined]) { + for (const config of [ + { baseUrl: 1n }, + { baseUrl, maxResponseBytes: Number.NaN }, + { baseUrl, requestTimeoutMilliseconds: Number.POSITIVE_INFINITY }, + undefined, + ]) { assert.throws( () => new RelayClient(config), (error) => error instanceof RelayClientError @@ -412,21 +506,43 @@ test('private-key JWT configuration is accepted without token-endpoint I/O', () const clientKey = privateKey.export({ format: 'jwk' }); clientKey.alg = 'ES256'; clientKey.kid = 'node-binding-test-key'; - const client = new RelayClient({ + const privateKeyJwt = { + tokenEndpoint: 'https://issuer.invalid/oauth/token', + clientId: 'node-binding-test-client', + clientKey, + audience: 'https://issuer.invalid/oauth/token', + assertionLifetimeSeconds: 300, + refreshMarginSeconds: 2 ** 32, + requestTimeoutMilliseconds: 2 ** 32, + connectTimeoutMilliseconds: 2 ** 32, + userAgent: 'registry-relay-client-node-test', + }; + const construct = (overrides = {}) => new RelayClient({ baseUrl, - authorization: { - privateKeyJwt: { - tokenEndpoint: 'https://issuer.invalid/oauth/token', - clientId: 'node-binding-test-client', - clientKey, - audience: 'https://issuer.invalid/oauth/token', - assertionLifetimeSeconds: 60, - refreshMarginSeconds: 10, - requestTimeoutMilliseconds: 1_000, - connectTimeoutMilliseconds: 500, - userAgent: 'registry-relay-client-node-test', - }, - }, + authorization: { privateKeyJwt: { ...privateKeyJwt, ...overrides } }, }); + const client = construct(); assert.ok(client); + + for (const [overrides, message] of [ + [ + { assertionLifetimeSeconds: 300.5 }, + 'authorization.privateKeyJwt.assertionLifetimeSeconds must be an integer', + ], + [ + { refreshMarginSeconds: Number.MAX_SAFE_INTEGER + 1 }, + 'authorization.privateKeyJwt.refreshMarginSeconds must be an integer', + ], + [ + { requestTimeoutMilliseconds: -1 }, + 'authorization.privateKeyJwt.requestTimeoutMilliseconds must be a non-negative integer', + ], + ]) { + assert.throws( + () => construct(overrides), + (error) => error instanceof RelayClientError + && error.kind === 'configuration' + && error.message === message, + ); + } }); diff --git a/crates/registry-relay-client-node/client.d.ts b/crates/registry-relay-client-node/client.d.ts index dedcfc0a9..19b270abb 100644 --- a/crates/registry-relay-client-node/client.d.ts +++ b/crates/registry-relay-client-node/client.d.ts @@ -1,5 +1,7 @@ export type JsonScalar = string | number | boolean | null export type JsonValue = JsonScalar | ReadonlyArray | { readonly [key: string]: JsonValue } +/** An integer that satisfies `Number.isSafeInteger` and the option's documented bounds. */ +export type SafeInteger = number export interface PrivateJwk { readonly kty: string @@ -13,10 +15,10 @@ export interface PrivateKeyJwtConfig { clientId: string clientKey: PrivateJwk audience?: string | null - assertionLifetimeSeconds?: number | null - refreshMarginSeconds?: number | null - requestTimeoutMilliseconds?: number | null - connectTimeoutMilliseconds?: number | null + assertionLifetimeSeconds?: SafeInteger | null + refreshMarginSeconds?: SafeInteger | null + requestTimeoutMilliseconds?: SafeInteger | null + connectTimeoutMilliseconds?: SafeInteger | null userAgent?: string | null trustedRootCertificates?: string | null } @@ -28,17 +30,17 @@ export type RelayAuthorization = export interface RelayClientConfig { baseUrl: string authorization?: RelayAuthorization | null - requestTimeoutMilliseconds?: number | null - connectTimeoutMilliseconds?: number | null + requestTimeoutMilliseconds?: SafeInteger | null + connectTimeoutMilliseconds?: SafeInteger | null userAgent?: string | null - maxResponseBytes?: number | null + maxResponseBytes?: SafeInteger | null trustedRootCertificates?: string | null } export type RecordFormat = 'json' | 'json-ld' | 'geojson' | 'geo-json-rfc7946' | 'json-fg' export interface ResourceListOptions { - pageSize?: number | null + pageSize?: SafeInteger | null } export interface ResourceContinuation { @@ -52,18 +54,17 @@ export interface RecordOptions { } export interface ListOptions extends RecordOptions { - pageSize?: number | null + pageSize?: SafeInteger | null filters?: Readonly> | null } export interface SearchOptions extends RecordOptions { - pageSize?: number | null + pageSize?: SafeInteger | null /** `[west, south, east, north]` in WGS84 longitude/latitude degrees. */ bbox: readonly [number, number, number, number] } -/** Numeric selectors must satisfy `Number.isSafeInteger`. */ -export type LookupSelector = string | number | boolean +export type LookupSelector = string | SafeInteger | boolean export type LookupSelectors = Readonly> export interface RecordsRoute { @@ -91,8 +92,8 @@ export interface SdmxDataRequest { version: string key?: string | null constraints?: Readonly> | null - offset?: number | null - limit?: number | null + offset?: SafeInteger | null + limit?: SafeInteger | null dimensionAtObservation?: string | null format?: 'json' | 'csv' | null } diff --git a/crates/registry-relay-client-node/src/lib.rs b/crates/registry-relay-client-node/src/lib.rs index 015af4c84..621f89dbd 100644 --- a/crates/registry-relay-client-node/src/lib.rs +++ b/crates/registry-relay-client-node/src/lib.rs @@ -29,6 +29,8 @@ type ResourceOutcome = Either; type CollectionOutcome = Either; type RawOutcome = Either; +const MAXIMUM_JAVASCRIPT_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + #[napi(object)] pub struct CompleteOutcome { pub kind: String, @@ -214,6 +216,39 @@ fn optional_string( } } +fn bounded_safe_integer( + value: &Value, + minimum: i64, + maximum: i64, + kind: &'static str, + message: &'static str, +) -> Result { + debug_assert!(minimum >= -MAXIMUM_JAVASCRIPT_SAFE_INTEGER); + debug_assert!(maximum <= MAXIMUM_JAVASCRIPT_SAFE_INTEGER); + + let invalid = || binding_error(kind, message); + let Value::Number(number) = value else { + return Err(invalid()); + }; + let integer = if let Some(integer) = number.as_i64() { + integer + } else { + let number = number.as_f64().ok_or_else(&invalid)?; + if !number.is_finite() + || number.fract() != 0.0 + || !(-(MAXIMUM_JAVASCRIPT_SAFE_INTEGER as f64)..=MAXIMUM_JAVASCRIPT_SAFE_INTEGER as f64) + .contains(&number) + { + return Err(invalid()); + } + number as i64 + }; + if !(minimum..=maximum).contains(&integer) { + return Err(invalid()); + } + Ok(integer) +} + fn optional_u64( object: &Map, field: &str, @@ -222,10 +257,10 @@ fn optional_u64( ) -> Result> { match object.get(field) { None | Some(Value::Null) => Ok(None), - Some(value) => value - .as_u64() - .map(Some) - .ok_or_else(|| binding_error(kind, message)), + Some(value) => { + bounded_safe_integer(value, 0, MAXIMUM_JAVASCRIPT_SAFE_INTEGER, kind, message) + .map(|value| Some(value as u64)) + } } } @@ -236,10 +271,14 @@ fn optional_i64( ) -> Result> { match object.get(field) { None | Some(Value::Null) => Ok(None), - Some(value) => value - .as_i64() - .map(Some) - .ok_or_else(|| binding_error("configuration", message)), + Some(value) => bounded_safe_integer( + value, + -MAXIMUM_JAVASCRIPT_SAFE_INTEGER, + MAXIMUM_JAVASCRIPT_SAFE_INTEGER, + "configuration", + message, + ) + .map(Some), } } @@ -611,11 +650,10 @@ fn request_optional_u32( ) -> Result> { match object.get(field) { None | Some(Value::Null) => Ok(None), - Some(value) => value - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .map(Some) - .ok_or_else(|| binding_error("invalid_request", message)), + Some(value) => { + bounded_safe_integer(value, 0, i64::from(u32::MAX), "invalid_request", message) + .map(|value| Some(value as u32)) + } } } @@ -743,34 +781,17 @@ fn record_options_request(value: Option) -> Result { } fn lookup_selector_value(value: &Value) -> Result { - const MAXIMUM_SAFE_INTEGER_I64: i64 = 9_007_199_254_740_991; - const MAXIMUM_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; - - let Value::Number(number) = value else { + if !value.is_number() { return Ok(value.clone()); - }; - if let Some(number) = number.as_i64() { - if (-MAXIMUM_SAFE_INTEGER_I64..=MAXIMUM_SAFE_INTEGER_I64).contains(&number) { - return Ok(value.clone()); - } - return Err(binding_error( - "invalid_request", - "a lookup selector value is invalid", - )); - } - let number = number - .as_f64() - .ok_or_else(|| binding_error("invalid_request", "a lookup selector value is invalid"))?; - if !number.is_finite() - || number.fract() != 0.0 - || !(-MAXIMUM_SAFE_INTEGER..=MAXIMUM_SAFE_INTEGER).contains(&number) - { - return Err(binding_error( - "invalid_request", - "a lookup selector value is invalid", - )); } - Ok(Value::from(number as i64)) + bounded_safe_integer( + value, + -MAXIMUM_JAVASCRIPT_SAFE_INTEGER, + MAXIMUM_JAVASCRIPT_SAFE_INTEGER, + "invalid_request", + "a lookup selector value is invalid", + ) + .map(Value::from) } fn collection_continuation(value: Value, expected: &'static str) -> Result { @@ -1263,6 +1284,53 @@ mod tests { .expect("the closed search query shape is accepted"); } + #[test] + fn public_integer_fields_share_the_javascript_safe_integer_decoder() { + let object = serde_json::from_str::( + r#"{ + "u64": 4294967296.0, + "i64": -9007199254740991.0, + "u32": 4294967295.0 + }"#, + ) + .expect("floating JSON numbers"); + let object = object.as_object().expect("an object"); + assert_eq!( + optional_u64(object, "u64", "configuration", "invalid").unwrap(), + Some(4_294_967_296) + ); + assert_eq!( + optional_i64(object, "i64", "invalid").unwrap(), + Some(-9_007_199_254_740_991) + ); + assert_eq!( + request_optional_u32(object, "u32", "invalid").unwrap(), + Some(u32::MAX) + ); + + for (wire, maximum, kind) in [ + ("1.5", MAXIMUM_JAVASCRIPT_SAFE_INTEGER, "configuration"), + ( + "9007199254740992", + MAXIMUM_JAVASCRIPT_SAFE_INTEGER, + "configuration", + ), + ( + "9007199254740992.0", + MAXIMUM_JAVASCRIPT_SAFE_INTEGER, + "configuration", + ), + ("4294967296.0", i64::from(u32::MAX), "invalid_request"), + ("-1", i64::from(u32::MAX), "invalid_request"), + ] { + let value = serde_json::from_str(wire).expect("a JSON number"); + let error = bounded_safe_integer(&value, 0, maximum, kind, "invalid").unwrap_err(); + let envelope = error_envelope(error); + assert_eq!(envelope["kind"], kind); + assert_eq!(envelope["message"], "invalid"); + } + } + #[test] fn javascript_safe_integer_selectors_become_signed_json_integers() { for (wire, expected) in [ diff --git a/crates/registry-relay-client-py/README.md b/crates/registry-relay-client-py/README.md index 49d1a07bf..d59983d38 100644 --- a/crates/registry-relay-client-py/README.md +++ b/crates/registry-relay-client-py/README.md @@ -51,8 +51,9 @@ accepts. Raw OpenAPI, artifact, and SDMX bodies are returned as `bytes`. List and search requests follow their distinct Relay contracts. A list accepts optional equality `filters` and never accepts `bbox`. A named search requires a -four-number `bbox` ordered as west, south, east, north and never accepts -`filters`: +four-number `bbox` ordered as west, south, east, north. The bbox must be a +concrete Python `list` or a four-item `tuple`; other sequence implementations +are rejected. Search never accepts `filters`: ```python listed = client.list_records("people", filters={"status": "active"}) diff --git a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi index 195716d3b..c6da3a509 100644 --- a/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi +++ b/crates/registry-relay-client-py/python/registry_relay_client/__init__.pyi @@ -6,6 +6,7 @@ RecordFormat = Literal["json", "json-ld", "geojson", "json-fg"] SdmxDataFormat = Literal["json", "csv"] SdmxStructureKind = Literal["dataflow", "datastructure"] Selector = Union[str, int, bool] +BoundingBox = list[float] | tuple[float, float, float, float] class _PrivateKeyJwtRequired(TypedDict): token_endpoint: str @@ -186,7 +187,7 @@ class RelayClient: resource: str, search: str, *, - bbox: Sequence[float], + bbox: BoundingBox, page_size: Optional[int] = ..., fields: Optional[Sequence[str]] = ..., access_profile: Optional[str] = ..., diff --git a/crates/registry-relay-client-py/tests/python/test_drift.py b/crates/registry-relay-client-py/tests/python/test_drift.py index 7e3346b69..b3332057d 100644 --- a/crates/registry-relay-client-py/tests/python/test_drift.py +++ b/crates/registry-relay-client-py/tests/python/test_drift.py @@ -39,9 +39,11 @@ def class_members(node: ast.ClassDef) -> set[str]: class DriftTest(unittest.TestCase): def setUp(self): - tree = ast.parse(STUB.read_text(encoding="utf-8")) + self.tree = ast.parse(STUB.read_text(encoding="utf-8")) self.stub_classes = { - node.name: node for node in tree.body if isinstance(node, ast.ClassDef) + node.name: node + for node in self.tree.body + if isinstance(node, ast.ClassDef) } self.classes = { name: node @@ -102,6 +104,40 @@ def test_list_and_search_signatures_match_both_directions(self): } self.assertEqual(stub_parameters, live_projection) + def test_bbox_type_matches_the_exact_runtime_containers(self): + alias = next( + node + for node in self.tree.body + if isinstance(node, ast.Assign) + and any( + isinstance(target, ast.Name) and target.id == "BoundingBox" + for target in node.targets + ) + ) + self.assertEqual( + ast.unparse(alias.value), + "list[float] | tuple[float, float, float, float]", + ) + self.assertFalse( + any( + isinstance(node, ast.Name) and node.id == "Sequence" + for node in ast.walk(alias.value) + ) + ) + + search = next( + node + for node in self.classes["RelayClient"].body + if isinstance(node, ast.FunctionDef) and node.name == "search" + ) + bbox = next( + argument + for argument in search.args.kwonlyargs + if argument.arg == "bbox" + ) + self.assertIsInstance(bbox.annotation, ast.Name) + self.assertEqual(bbox.annotation.id, "BoundingBox") + def test_error_attributes_and_inheritance_are_pinned(self): self.assertEqual( class_members(self.classes["RelayClientError"]), ERROR_ATTRIBUTES diff --git a/crates/registry-relay-client-py/tests/python/test_request_shapes.py b/crates/registry-relay-client-py/tests/python/test_request_shapes.py index 5d92b6347..45139059c 100644 --- a/crates/registry-relay-client-py/tests/python/test_request_shapes.py +++ b/crates/registry-relay-client-py/tests/python/test_request_shapes.py @@ -1,13 +1,17 @@ from __future__ import annotations +from collections import UserList import inspect import pathlib import sys +from types import MappingProxyType import unittest +from urllib.parse import parse_qs, urlsplit TESTS = pathlib.Path(__file__).resolve().parent sys.path.insert(0, str(TESTS)) import bootstrap # noqa: E402 +from relay_server import RelayServer, Request, json_response, record_collection # noqa: E402 bootstrap.ensure_built() import registry_relay_client as relay # noqa: E402 @@ -60,6 +64,50 @@ def test_search_rejects_invalid_bbox_without_exposing_values(self): self.client.search("people", "nearby", bbox=cyclic) self.assertEqual(cycle.exception.kind, "invalid_request") + def test_search_accepts_concrete_list_and_tuple_bounding_boxes(self): + queries: list[dict[str, list[str]]] = [] + + def respond(request: Request): + target = urlsplit(request.target) + self.assertEqual( + target.path, "/prefix/v2/resources/people/searches/nearby" + ) + queries.append(parse_qs(target.query)) + return json_response(record_collection(None)) + + with RelayServer(respond) as server: + client = relay.RelayClient(server.base_url) + for bbox in ([10.0, 20.0, 11.0, 21.0], (10.0, 20.0, 11.0, 21.0)): + with self.subTest(container=type(bbox).__name__): + self.assertEqual( + client.search("people", "nearby", bbox=bbox)["kind"], + "complete", + ) + + self.assertEqual( + queries, + [ + {"bbox": ["10,20,11,21"]}, + {"bbox": ["10,20,11,21"]}, + ], + ) + + def test_search_rejects_exotic_sequences_as_stable_client_errors(self): + values = ( + UserList([10.0, 20.0, 11.0, 21.0]), + range(4), + MappingProxyType({"west": 10.0, "south": 20.0}), + ) + for bbox in values: + with self.subTest(container=type(bbox).__name__): + with self.assertRaises(relay.RelayClientError) as raised: + self.client.search("people", "nearby", bbox=bbox) + self.assertEqual(raised.exception.kind, "invalid_request") + self.assertEqual( + str(raised.exception), + "a value of this Python type cannot be converted", + ) + def test_unsigned_request_ranges_are_stable_invalid_request_errors(self): calls = ( ("resources", lambda value: self.client.resources(page_size=value), "page_size"), diff --git a/crates/registry-relay-client/src/client.rs b/crates/registry-relay-client/src/client.rs index e75107d3d..1eab95c55 100644 --- a/crates/registry-relay-client/src/client.rs +++ b/crates/registry-relay-client/src/client.rs @@ -260,10 +260,10 @@ impl RelayClient { "sdmx", "v2", "structure", - request.kind.path(), - &request.agency, - &request.resource, - &request.version, + request.kind().path(), + request.agency(), + request.resource(), + request.version(), ], &[("references".into(), "none".into())], SDMX_STRUCTURE_JSON, diff --git a/crates/registry-relay-client/src/query.rs b/crates/registry-relay-client/src/query.rs index beb0dad88..d735d08f2 100644 --- a/crates/registry-relay-client/src/query.rs +++ b/crates/registry-relay-client/src/query.rs @@ -531,12 +531,28 @@ impl SdmxStructureKind { } } +/// A validated SDMX structure route. +/// +/// Route fields are read-only so an invalid value cannot be substituted after +/// construction and sent to Relay. +/// +/// ```compile_fail +/// use registry_relay_client::{SdmxStructureKind, SdmxStructureRequest}; +/// +/// let mut request = SdmxStructureRequest::new( +/// SdmxStructureKind::Dataflow, +/// "AGENCY", +/// "FLOW", +/// "1.0.0", +/// ).unwrap(); +/// request.version = "latest".into(); +/// ``` #[derive(Clone, Debug, PartialEq, Eq)] pub struct SdmxStructureRequest { - pub kind: SdmxStructureKind, - pub agency: String, - pub resource: String, - pub version: String, + kind: SdmxStructureKind, + agency: String, + resource: String, + version: String, } impl SdmxStructureRequest { @@ -564,6 +580,26 @@ impl SdmxStructureRequest { } Ok(result) } + + #[must_use] + pub const fn kind(&self) -> SdmxStructureKind { + self.kind + } + + #[must_use] + pub fn agency(&self) -> &str { + &self.agency + } + + #[must_use] + pub fn resource(&self) -> &str { + &self.resource + } + + #[must_use] + pub fn version(&self) -> &str { + &self.version + } } fn validate_identifier(value: &str, reason: &'static str) -> Result<(), RelayClientError> { @@ -723,6 +759,25 @@ mod tests { assert!(base.dimension_at_observation("time_period").is_err()); } + #[test] + fn sdmx_structure_route_fields_remain_validated_and_read_only() { + let request = SdmxStructureRequest::new( + SdmxStructureKind::DataStructure, + "AGENCY.SUB", + "FLOW", + "1.0.0", + ) + .expect("valid structure request"); + assert_eq!(request.kind(), SdmxStructureKind::DataStructure); + assert_eq!(request.agency(), "AGENCY.SUB"); + assert_eq!(request.resource(), "FLOW"); + assert_eq!(request.version(), "1.0.0"); + assert!( + SdmxStructureRequest::new(SdmxStructureKind::Dataflow, "AGENCY", "FLOW", "latest",) + .is_err() + ); + } + #[test] fn keyed_sdmx_preserves_bounded_non_code_strings_but_rejects_query_grammar() { let base = SdmxDataRequest::new("A", "F", "1.0.0").unwrap();