diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec5678a..2efdde5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,17 @@ on: pull_request: jobs: + family-connection-v1-vectors: + name: family connection v1 vectors + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - run: python -m pip install --require-hashes -r scripts/requirements-family-connection-v1-vectors.txt + - run: python3 scripts/validate-family-connection-v1-vectors.py + python: name: python launcher runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 8f201d5..957980a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,8 +40,12 @@ that ties the family together and reserves the `determa` name on every registry. - **No AI/assistant attribution** anywhere (commits, PRs, comments, docs). - Keep the three launchers **behaviorally identical** (same dispatch, env-var selection, `list` grouping, help text). Each has its own test suite. -## Gates (`ci.yml` runs all three) +## Gates (`ci.yml` runs the family vectors and all three launchers) ```sh +# family connection/context vectors +python3 -m pip install --require-hashes -r scripts/requirements-family-connection-v1-vectors.txt +python3 scripts/validate-family-connection-v1-vectors.py + # python cd python && pip install -e '.[dev]' && ruff check . && pytest -q # rust diff --git a/README.md b/README.md index 9908842..9183d17 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,10 @@ reserves the language-neutral connection, context, endpoint-routing, and future family command contract. It is design documentation only: the current launchers remain local dispatchers and do not yet implement remote connections or clients. +The machine-readable [v1 conformance vectors](conformance/family-connection-v1/) +fix the expected configuration, endpoint, environment-name, and routing results +without adding those behaviors to the launchers. + ## License MIT — see [LICENSE](LICENSE). diff --git a/conformance/family-connection-v1/README.md b/conformance/family-connection-v1/README.md new file mode 100644 index 0000000..e3c934e --- /dev/null +++ b/conformance/family-connection-v1/README.md @@ -0,0 +1,87 @@ +# Family Connection/Context v1 conformance vectors + +These JSON fixtures are the ecosystem-neutral executable examples for +[Family Connection and Context Configuration v1](../../docs/family-connection-context-v1.md). +They do not define a production client or resolver. + +## Suites + +- `configuration.json` stores configuration inputs as JSON source strings so + integer spellings and duplicate keys remain testable. Successful cases return + the closed normalized logical value; rejected cases return an error code. +- `endpoints.json` maps endpoint inputs to canonical endpoints or rejection + codes. +- `environment.json` maps product/resource names to environment override names + and declares sets that must remain pairwise distinct. +- `routing.json` defines reusable valid configurations and request cases for + precedence, exact/product matching, invalid-value handling, and context + fallback. + +Every case has a repository-wide unique `id` and an `expect` object containing +exactly one of: + +```json +{"value": "canonical output"} +``` + +```json +{"error": "stable_error_code"} +``` + +## Error codes + +| code | meaning | +|---|---| +| `duplicate_key` | a source object repeats a key before model construction | +| `invalid_source` | source JSON is malformed, unavailable as a string, or contains a non-JSON numeric constant | +| `missing_field` | a required closed-model field is absent | +| `unknown_field` | a closed-model object contains an undeclared field | +| `invalid_type` | a value has a type not accepted at that location | +| `invalid_version` | `version` is not the integer token `1` | +| `invalid_name` | a connection, context, provider, product, or resource name is invalid | +| `invalid_reference` | a context or route names an absent object | +| `invalid_endpoint_type` | an endpoint is not a string | +| `invalid_endpoint_characters` | an endpoint contains forbidden whitespace, controls, surrogates, or backslash | +| `invalid_endpoint_syntax` | an endpoint is empty, relative, opaque, or has a query/fragment | +| `unsupported_endpoint_scheme` | an endpoint scheme is not HTTP(S) | +| `invalid_endpoint_authority` | authority structure, user-info, or IP brackets are invalid | +| `invalid_endpoint_host` | IDNA, IPv4, IPv6, zone, or host-label validation fails | +| `invalid_endpoint_port` | a port is empty, non-canonical, zero, or out of range | +| `invalid_endpoint_path` | path syntax or percent encoding is unsupported | +| `insecure_endpoint` | HTTP targets a non-loopback host | +| `invalid_environment` | environment overrides are not a string map | +| `invalid_connection` | a present override is empty or names an absent connection | +| `invalid_context` | a selected context is not a configured context name | +| `unresolved_connection` | every resolution level is absent | + +These codes classify vector outcomes only. A later public client/protocol error +contract requires its own specification and conformance work. + +## Validation + +From the repository root: + +```sh +python3 -m pip install --require-hashes \ + -r scripts/requirements-family-connection-v1-vectors.txt +python3 scripts/validate-family-connection-v1-vectors.py +``` + +The test-only IDNA path uses the hash-pinned `idna==3.7` package as a source for +tables generated from the official [Unicode 15.1.0 IDNA mapping table](https://www.unicode.org/Public/idna/15.1.0/IdnaMappingTable.txt) +and for the ContextJ and RFC 5893 bidi helpers referenced by UTS #46. The +harness verifies at startup that the package's IDNA and UTS #46 data report +exactly Unicode 15.1.0 and that the Python runtime supplies Unicode data at +least as new as 15.1.0. + +The harness implements revision-31 ToASCII directly: map, normalize to NFC, +split labels, decode and validate existing Punycode, apply the configured +hyphen, status, ContextJ, bidi, and DNS-length criteria, then Punycode-encode +non-ASCII labels. It deliberately does not call `idna.encode`, because that +API adds IDNA2008 code-point eligibility beyond the merged contract. The exact +15.1 UTS #46 status table remains authoritative when a later Python runtime +knows newer code points. Positive NV8, normalization, Unicode-version, and +ContextJ/bidi vectors prevent reject-all or IDNA2008-only implementations from +passing. The harness independently applies the remaining v1 strict URI, +configuration, environment-name, and routing rules and is not shipped in any +launcher package. diff --git a/conformance/family-connection-v1/configuration.json b/conformance/family-connection-v1/configuration.json new file mode 100644 index 0000000..f89624b --- /dev/null +++ b/conformance/family-connection-v1/configuration.json @@ -0,0 +1,278 @@ +{ + "contract": "family-connection-context-v1", + "suite": "configuration", + "cases": [ + { + "id": "config-minimal-empty", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{}}", + "expect": { + "value": { + "version": 1, + "connections": {}, + "contexts": {} + } + } + }, + { + "id": "config-explicit-empty-defaults", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{},\"defaults\":{\"routes\":{}}}", + "expect": { + "value": { + "version": 1, + "connections": {}, + "contexts": {}, + "defaults": {"routes": {}} + } + } + }, + { + "id": "config-empty-default-context", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{\"personal\":{\"routes\":{}}},\"default_context\":\"personal\"}", + "expect": { + "value": { + "version": 1, + "connections": {}, + "contexts": {"personal": {"routes": {}}}, + "default_context": "personal" + } + } + }, + { + "id": "config-full-normalized", + "source": "{\"version\":1,\"default_context\":\"personal\",\"connections\":{\"cloud\":{\"endpoint\":\"HTTPS://BÜCHER.example:443/a/./\",\"credential_ref\":{\"provider\":\"environment\",\"name\":\"DETERMA_TOKEN\"}}},\"contexts\":{\"personal\":{\"routes\":{\"state\":\"cloud\",\"state/instances\":\"cloud\"}}},\"defaults\":{\"routes\":{\"guard\":\"cloud\"}}}", + "expect": { + "value": { + "version": 1, + "connections": { + "cloud": { + "endpoint": "https://xn--bcher-kva.example/a", + "credential_ref": { + "provider": "environment", + "name": "DETERMA_TOKEN" + } + } + }, + "contexts": { + "personal": { + "routes": { + "state": "cloud", + "state/instances": "cloud" + } + } + }, + "default_context": "personal", + "defaults": {"routes": {"guard": "cloud"}} + } + } + }, + { + "id": "config-separate-name-namespaces", + "source": "{\"version\":1,\"connections\":{\"same\":{\"endpoint\":\"https://example.com\"},\"other\":{\"endpoint\":\"https://example.com/\"}},\"contexts\":{\"same\":{\"routes\":{\"state\":\"other\"}}}}", + "expect": { + "value": { + "version": 1, + "connections": { + "same": {"endpoint": "https://example.com/"}, + "other": {"endpoint": "https://example.com/"} + }, + "contexts": {"same": {"routes": {"state": "other"}}} + } + } + }, + { + "id": "config-version-string", + "source": "{\"version\":\"1\",\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_version"} + }, + { + "id": "config-version-float", + "source": "{\"version\":1.0,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_version"} + }, + { + "id": "config-version-exponent", + "source": "{\"version\":1e0,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_version"} + }, + { + "id": "config-version-two", + "source": "{\"version\":2,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_version"} + }, + { + "id": "config-version-boolean", + "source": "{\"version\":true,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_version"} + }, + { + "id": "config-version-null", + "source": "{\"version\":null,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_version"} + }, + { + "id": "config-root-not-object", + "source": "[]", + "expect": {"error": "invalid_type"} + }, + { + "id": "config-missing-version", + "source": "{\"connections\":{},\"contexts\":{}}", + "expect": {"error": "missing_field"} + }, + { + "id": "config-missing-connections", + "source": "{\"version\":1,\"contexts\":{}}", + "expect": {"error": "missing_field"} + }, + { + "id": "config-missing-contexts", + "source": "{\"version\":1,\"connections\":{}}", + "expect": {"error": "missing_field"} + }, + { + "id": "config-connections-not-map", + "source": "{\"version\":1,\"connections\":[],\"contexts\":{}}", + "expect": {"error": "invalid_type"} + }, + { + "id": "config-contexts-not-map", + "source": "{\"version\":1,\"connections\":{},\"contexts\":[]}", + "expect": {"error": "invalid_type"} + }, + { + "id": "config-unknown-root-field", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{},\"extra\":{}}", + "expect": {"error": "unknown_field"} + }, + { + "id": "config-invalid-connection-name", + "source": "{\"version\":1,\"connections\":{\"Cloud\":{\"endpoint\":\"https://example.com\"}},\"contexts\":{}}", + "expect": {"error": "invalid_name"} + }, + { + "id": "config-connection-not-object", + "source": "{\"version\":1,\"connections\":{\"cloud\":null},\"contexts\":{}}", + "expect": {"error": "invalid_type"} + }, + { + "id": "config-connection-missing-endpoint", + "source": "{\"version\":1,\"connections\":{\"cloud\":{}},\"contexts\":{}}", + "expect": {"error": "missing_field"} + }, + { + "id": "config-connection-unknown-field", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\",\"extra\":true}},\"contexts\":{}}", + "expect": {"error": "unknown_field"} + }, + { + "id": "config-empty-endpoint", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"\"}},\"contexts\":{}}", + "expect": {"error": "invalid_endpoint_syntax"} + }, + { + "id": "config-insecure-endpoint", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"http://example.com\"}},\"contexts\":{}}", + "expect": {"error": "insecure_endpoint"} + }, + { + "id": "config-null-credential-reference", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\",\"credential_ref\":null}},\"contexts\":{}}", + "expect": {"error": "invalid_type"} + }, + { + "id": "config-credential-unknown-field", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\",\"credential_ref\":{\"provider\":\"environment\",\"name\":\"TOKEN\",\"extra\":true}}},\"contexts\":{}}", + "expect": {"error": "unknown_field"} + }, + { + "id": "config-invalid-credential-provider", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\",\"credential_ref\":{\"provider\":\"Environment\",\"name\":\"TOKEN\"}}},\"contexts\":{}}", + "expect": {"error": "invalid_name"} + }, + { + "id": "config-empty-credential-name", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\",\"credential_ref\":{\"provider\":\"environment\",\"name\":\"\"}}},\"contexts\":{}}", + "expect": {"error": "invalid_name"} + }, + { + "id": "config-invalid-context-name", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{\"Personal\":{\"routes\":{}}}}", + "expect": {"error": "invalid_name"} + }, + { + "id": "config-context-missing-routes", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{\"personal\":{}}}", + "expect": {"error": "missing_field"} + }, + { + "id": "config-context-unknown-field", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{\"personal\":{\"routes\":{},\"extra\":true}}}", + "expect": {"error": "unknown_field"} + }, + { + "id": "config-routes-not-map", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{\"personal\":{\"routes\":[]}}}", + "expect": {"error": "invalid_type"} + }, + { + "id": "config-invalid-route-name", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\"}},\"contexts\":{\"personal\":{\"routes\":{\"state/foo_bar\":\"cloud\"}}}}", + "expect": {"error": "invalid_name"} + }, + { + "id": "config-route-target-not-string", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\"}},\"contexts\":{\"personal\":{\"routes\":{\"state\":1}}}}", + "expect": {"error": "invalid_type"} + }, + { + "id": "config-missing-route-connection", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{\"personal\":{\"routes\":{\"state\":\"cloud\"}}}}", + "expect": {"error": "invalid_reference"} + }, + { + "id": "config-missing-default-context", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{},\"default_context\":\"personal\"}", + "expect": {"error": "invalid_reference"} + }, + { + "id": "config-defaults-missing-routes", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{},\"defaults\":{}}", + "expect": {"error": "missing_field"} + }, + { + "id": "config-duplicate-root-key", + "source": "{\"version\":1,\"version\":1,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "duplicate_key"} + }, + { + "id": "config-duplicate-connection-name", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://one.example\"},\"cloud\":{\"endpoint\":\"https://two.example\"}},\"contexts\":{}}", + "expect": {"error": "duplicate_key"} + }, + { + "id": "config-duplicate-route-name", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\"}},\"contexts\":{\"personal\":{\"routes\":{\"state\":\"cloud\",\"state\":\"cloud\"}}}}", + "expect": {"error": "duplicate_key"} + }, + { + "id": "config-invalid-json-source", + "source": "{\"version\":1,", + "expect": {"error": "invalid_source"} + }, + { + "id": "config-nan-source", + "source": "{\"version\":NaN,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_source"} + }, + { + "id": "config-positive-infinity-source", + "source": "{\"version\":Infinity,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_source"} + }, + { + "id": "config-negative-infinity-source", + "source": "{\"version\":-Infinity,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_source"} + } + ] +} diff --git a/conformance/family-connection-v1/endpoints.json b/conformance/family-connection-v1/endpoints.json new file mode 100644 index 0000000..232e7e4 --- /dev/null +++ b/conformance/family-connection-v1/endpoints.json @@ -0,0 +1,341 @@ +{ + "contract": "family-connection-context-v1", + "suite": "endpoints", + "cases": [ + { + "id": "endpoint-root-empty-path", + "input": "https://EXAMPLE.com", + "expect": {"value": "https://example.com/"} + }, + { + "id": "endpoint-idna-dot-percent-default-port", + "input": "HTTPS://bücher.example:443/a/./b/%7euser/", + "expect": {"value": "https://xn--bcher-kva.example/a/b/~user"} + }, + { + "id": "endpoint-idna-nfd-equivalent", + "input": "https://bu\u0308cher.example", + "expect": {"value": "https://xn--bcher-kva.example/"} + }, + { + "id": "endpoint-idna-sharp-s", + "input": "https://faß.example", + "expect": {"value": "https://xn--fa-hia.example/"} + }, + { + "id": "endpoint-idna-capital-sharp-s", + "input": "https://faẞ.example", + "expect": {"value": "https://xn--fa-hia.example/"} + }, + { + "id": "endpoint-idna-canonical-punycode", + "input": "https://XN--BCHER-KVA.example", + "expect": {"value": "https://xn--bcher-kva.example/"} + }, + { + "id": "endpoint-idna-unicode-separator", + "input": "https://example。com", + "expect": {"value": "https://example.com/"} + }, + { + "id": "endpoint-idna-unicode-15-1-new-codepoint", + "input": "https://\ud87a\udff0.example", + "expect": {"value": "https://xn--8g0n.example/"} + }, + { + "id": "endpoint-idna-valid-nv8", + "input": "https://☕.example/", + "expect": {"value": "https://xn--53h.example/"} + }, + { + "id": "endpoint-idna-valid-contextj-bidi", + "input": "https://\u0646\u0627\u0645\u0647\u200c\u0627\u06cc.example", + "expect": {"value": "https://xn--mgba3gch31f060k.example/"} + }, + { + "id": "endpoint-custom-port-repeated-slashes", + "input": "https://example.com:8443/a//b/", + "expect": {"value": "https://example.com:8443/a//b"} + }, + { + "id": "endpoint-encoded-dot-and-reserved", + "input": "https://example.com/a/%2E%2E/b/%3a", + "expect": {"value": "https://example.com/b/%3A"} + }, + { + "id": "endpoint-unreserved-percent-decoded", + "input": "https://example.com/%41/%7a/%7E", + "expect": {"value": "https://example.com/A/z/~"} + }, + { + "id": "endpoint-encoded-space-preserved", + "input": "https://example.com/a%20b", + "expect": {"value": "https://example.com/a%20b"} + }, + { + "id": "endpoint-all-slashes-become-root", + "input": "https://example.com////", + "expect": {"value": "https://example.com/"} + }, + { + "id": "endpoint-http-localhost", + "input": "HTTP://LOCALHOST:80", + "expect": {"value": "http://localhost/"} + }, + { + "id": "endpoint-http-ipv4-loopback", + "input": "http://127.0.0.1:80", + "expect": {"value": "http://127.0.0.1/"} + }, + { + "id": "endpoint-http-ipv4-loopback-range", + "input": "http://127.255.255.255:8080/a", + "expect": {"value": "http://127.255.255.255:8080/a"} + }, + { + "id": "endpoint-https-ipv4", + "input": "https://192.0.2.1:443/api/", + "expect": {"value": "https://192.0.2.1/api"} + }, + { + "id": "endpoint-http-ipv6-loopback", + "input": "http://[0:0:0:0:0:0:0:1]:8080/a/..", + "expect": {"value": "http://[::1]:8080/"} + }, + { + "id": "endpoint-ipv6-first-longest-zero-run", + "input": "https://[2001:0:0:1:0:0:1:1]", + "expect": {"value": "https://[2001::1:0:0:1:1]/"} + }, + { + "id": "endpoint-ipv4-embedded-ipv6-pure-hex", + "input": "https://[::ffff:192.0.2.1]", + "expect": {"value": "https://[::ffff:c000:201]/"} + }, + { + "id": "endpoint-dns-label-63-bytes", + "input": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example", + "expect": {"value": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example/"} + }, + { + "id": "endpoint-dns-domain-253-bytes", + "input": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "expect": {"value": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.ddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd/"} + }, + { + "id": "endpoint-input-not-string", + "input": null, + "expect": {"error": "invalid_endpoint_type"} + }, + { + "id": "endpoint-empty", + "input": "", + "expect": {"error": "invalid_endpoint_syntax"} + }, + { + "id": "endpoint-leading-space", + "input": " https://example.com", + "expect": {"error": "invalid_endpoint_characters"} + }, + { + "id": "endpoint-control-character", + "input": "https://example.com/\u0009path", + "expect": {"error": "invalid_endpoint_characters"} + }, + { + "id": "endpoint-backslash", + "input": "https://example.com\\path", + "expect": {"error": "invalid_endpoint_characters"} + }, + { + "id": "endpoint-relative", + "input": "example.com", + "expect": {"error": "invalid_endpoint_syntax"} + }, + { + "id": "endpoint-opaque", + "input": "https:example.com", + "expect": {"error": "invalid_endpoint_syntax"} + }, + { + "id": "endpoint-unsupported-scheme", + "input": "ftp://example.com/", + "expect": {"error": "unsupported_endpoint_scheme"} + }, + { + "id": "endpoint-empty-host", + "input": "https:///path", + "expect": {"error": "invalid_endpoint_authority"} + }, + { + "id": "endpoint-user-info", + "input": "https://user@example.com/", + "expect": {"error": "invalid_endpoint_authority"} + }, + { + "id": "endpoint-query", + "input": "https://example.com/?x=1", + "expect": {"error": "invalid_endpoint_syntax"} + }, + { + "id": "endpoint-fragment", + "input": "https://example.com/#x", + "expect": {"error": "invalid_endpoint_syntax"} + }, + { + "id": "endpoint-percent-encoded-host", + "input": "https://exa%6dple.com/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-root-dot-host", + "input": "https://example.com./", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-empty-host-label", + "input": "https://example..com/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-invalid-idna-label", + "input": "https://-bad.example/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-invalid-idna-hyphen-positions-three-four", + "input": "https://ab--cd.example/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-invalid-idna-zero-width-joiner", + "input": "https://a\u200db.example/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-invalid-idna-non-breaking-space", + "input": "https://a\u00a0b.example/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-invalid-punycode", + "input": "https://xn--a.example/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-idna-unicode-16-codepoint-disallowed-in-15-1", + "input": "https://\u1c8a.example/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-dns-label-64-bytes", + "input": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.example/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-dns-domain-254-bytes", + "input": "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc.dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-short-ipv4", + "input": "https://127.1/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-leading-zero-ipv4", + "input": "https://0177.0.0.1/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-hex-ipv4", + "input": "https://0x7f000001/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-ipv4-out-of-range", + "input": "https://256.0.0.1/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-unbracketed-ipv6", + "input": "https://2001:db8::1/", + "expect": {"error": "invalid_endpoint_authority"} + }, + { + "id": "endpoint-ipv6-zone", + "input": "http://[fe80::1%25en0]/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-ipvfuture", + "input": "https://[v1.example]/", + "expect": {"error": "invalid_endpoint_host"} + }, + { + "id": "endpoint-empty-port", + "input": "https://example.com:/", + "expect": {"error": "invalid_endpoint_port"} + }, + { + "id": "endpoint-leading-zero-port", + "input": "https://example.com:0443/", + "expect": {"error": "invalid_endpoint_port"} + }, + { + "id": "endpoint-zero-port", + "input": "https://example.com:0/", + "expect": {"error": "invalid_endpoint_port"} + }, + { + "id": "endpoint-out-of-range-port", + "input": "https://example.com:65536/", + "expect": {"error": "invalid_endpoint_port"} + }, + { + "id": "endpoint-encoded-slash", + "input": "https://example.com/%2f", + "expect": {"error": "invalid_endpoint_path"} + }, + { + "id": "endpoint-encoded-backslash", + "input": "https://example.com/%5C", + "expect": {"error": "invalid_endpoint_path"} + }, + { + "id": "endpoint-encoded-non-ascii", + "input": "https://example.com/%C3%A9", + "expect": {"error": "invalid_endpoint_path"} + }, + { + "id": "endpoint-encoded-control", + "input": "https://example.com/%00", + "expect": {"error": "invalid_endpoint_path"} + }, + { + "id": "endpoint-malformed-percent", + "input": "https://example.com/%2", + "expect": {"error": "invalid_endpoint_path"} + }, + { + "id": "endpoint-raw-unicode-path", + "input": "https://example.com/café", + "expect": {"error": "invalid_endpoint_path"} + }, + { + "id": "endpoint-http-non-loopback-host", + "input": "http://example.com/", + "expect": {"error": "insecure_endpoint"} + }, + { + "id": "endpoint-http-non-loopback-ipv4", + "input": "http://192.0.2.1/", + "expect": {"error": "insecure_endpoint"} + }, + { + "id": "endpoint-http-mapped-loopback-is-not-ipv6-loopback", + "input": "http://[::ffff:127.0.0.1]/", + "expect": {"error": "insecure_endpoint"} + } + ] +} diff --git a/conformance/family-connection-v1/environment.json b/conformance/family-connection-v1/environment.json new file mode 100644 index 0000000..05baa19 --- /dev/null +++ b/conformance/family-connection-v1/environment.json @@ -0,0 +1,85 @@ +{ + "contract": "family-connection-context-v1", + "suite": "environment", + "distinct_sets": [ + ["environment-hyphen", "environment-segment-boundary", "environment-double-hyphen"], + ["environment-product-hyphen", "environment-product-segment-boundary"] + ], + "cases": [ + { + "id": "environment-product", + "resource": "state", + "expect": {"value": "DETERMA_STATE_CONNECTION"} + }, + { + "id": "environment-resource", + "resource": "state/instances", + "expect": {"value": "DETERMA_STATE__INSTANCES_CONNECTION"} + }, + { + "id": "environment-hyphen", + "resource": "state/foo-bar", + "expect": {"value": "DETERMA_STATE__FOO_HBAR_CONNECTION"} + }, + { + "id": "environment-segment-boundary", + "resource": "state/foo/bar", + "expect": {"value": "DETERMA_STATE__FOO__BAR_CONNECTION"} + }, + { + "id": "environment-double-hyphen", + "resource": "state/foo--bar", + "expect": {"value": "DETERMA_STATE__FOO_H_HBAR_CONNECTION"} + }, + { + "id": "environment-product-hyphen", + "resource": "state-machine/foo", + "expect": {"value": "DETERMA_STATE_HMACHINE__FOO_CONNECTION"} + }, + { + "id": "environment-product-segment-boundary", + "resource": "state/machine/foo", + "expect": {"value": "DETERMA_STATE__MACHINE__FOO_CONNECTION"} + }, + { + "id": "environment-digits", + "resource": "ledger2/account-3", + "expect": {"value": "DETERMA_LEDGER2__ACCOUNT_H3_CONNECTION"} + }, + { + "id": "environment-empty-resource", + "resource": "", + "expect": {"error": "invalid_name"} + }, + { + "id": "environment-uppercase-resource", + "resource": "State", + "expect": {"error": "invalid_name"} + }, + { + "id": "environment-underscore-resource", + "resource": "state/foo_bar", + "expect": {"error": "invalid_name"} + }, + { + "id": "environment-leading-digit-segment", + "resource": "state/2fa", + "expect": {"error": "invalid_name"} + }, + { + "id": "environment-empty-segment", + "resource": "state//instances", + "expect": {"error": "invalid_name"} + }, + { + "id": "environment-trailing-separator", + "resource": "state/", + "expect": {"error": "invalid_name"} + }, + { + "id": "environment-resource-not-string", + "resource": null, + "expect": {"error": "invalid_type"} + } + ] +} diff --git a/conformance/family-connection-v1/routing.json b/conformance/family-connection-v1/routing.json new file mode 100644 index 0000000..4ff5bd0 --- /dev/null +++ b/conformance/family-connection-v1/routing.json @@ -0,0 +1,228 @@ +{ + "contract": "family-connection-context-v1", + "suite": "routing", + "configurations": { + "all-levels": "{\"version\":1,\"connections\":{\"explicit\":{\"endpoint\":\"https://explicit.example\"},\"environment-exact\":{\"endpoint\":\"https://environment-exact.example\"},\"environment-product\":{\"endpoint\":\"https://environment-product.example\"},\"environment-global\":{\"endpoint\":\"https://environment-global.example\"},\"context-exact\":{\"endpoint\":\"https://context-exact.example\"},\"context-product\":{\"endpoint\":\"https://context-product.example\"},\"default-exact\":{\"endpoint\":\"https://default-exact.example\"},\"default-product\":{\"endpoint\":\"https://default-product.example\"},\"global-exact\":{\"endpoint\":\"https://global-exact.example\"},\"global-product\":{\"endpoint\":\"https://global-product.example\"}},\"contexts\":{\"selected\":{\"routes\":{\"state/foo\":\"context-exact\",\"state\":\"context-product\"}},\"empty\":{\"routes\":{}},\"global\":{\"routes\":{\"state/foo\":\"global-exact\",\"state\":\"global-product\"}}},\"default_context\":\"global\",\"defaults\":{\"routes\":{\"state/foo\":\"default-exact\",\"state\":\"default-product\"}}}", + "global-only": "{\"version\":1,\"connections\":{\"global-exact\":{\"endpoint\":\"https://global-exact.example\"},\"global-product\":{\"endpoint\":\"https://global-product.example\"}},\"contexts\":{\"empty\":{\"routes\":{}},\"global\":{\"routes\":{\"state/foo\":\"global-exact\",\"state\":\"global-product\"}}},\"default_context\":\"global\"}", + "no-fallback": "{\"version\":1,\"connections\":{\"available\":{\"endpoint\":\"https://available.example\"}},\"contexts\":{}}" + }, + "cases": [ + { + "id": "routing-explicit-wins", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "explicit_connection": "explicit", + "environment": { + "DETERMA_STATE__FOO_CONNECTION": "environment-exact", + "DETERMA_STATE_CONNECTION": "environment-product", + "DETERMA_CONNECTION": "environment-global" + }, + "selected_context": "selected" + }, + "expect": {"value": "explicit"} + }, + { + "id": "routing-invalid-explicit-no-fallthrough", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "explicit_connection": "missing", + "environment": {"DETERMA_STATE__FOO_CONNECTION": "environment-exact"}, + "selected_context": "selected" + }, + "expect": {"error": "invalid_connection"} + }, + { + "id": "routing-environment-exact-wins", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "environment": { + "DETERMA_STATE__FOO_CONNECTION": "environment-exact", + "DETERMA_STATE_CONNECTION": "environment-product", + "DETERMA_CONNECTION": "environment-global" + }, + "selected_context": "selected" + }, + "expect": {"value": "environment-exact"} + }, + { + "id": "routing-environment-product-wins", + "configuration": "all-levels", + "request": { + "resource": "state/other", + "environment": { + "DETERMA_STATE_CONNECTION": "environment-product", + "DETERMA_CONNECTION": "environment-global" + }, + "selected_context": "selected" + }, + "expect": {"value": "environment-product"} + }, + { + "id": "routing-environment-global-wins", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "environment": {"DETERMA_CONNECTION": "environment-global"}, + "selected_context": "selected" + }, + "expect": {"value": "environment-global"} + }, + { + "id": "routing-empty-environment-exact-no-fallthrough", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "environment": {"DETERMA_STATE__FOO_CONNECTION": ""}, + "selected_context": "selected" + }, + "expect": {"error": "invalid_connection"} + }, + { + "id": "routing-missing-environment-exact-no-fallthrough", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "environment": {"DETERMA_STATE__FOO_CONNECTION": "missing"}, + "selected_context": "selected" + }, + "expect": {"error": "invalid_connection"} + }, + { + "id": "routing-missing-environment-product-no-fallthrough", + "configuration": "all-levels", + "request": { + "resource": "state/other", + "environment": {"DETERMA_STATE_CONNECTION": "missing"}, + "selected_context": "selected" + }, + "expect": {"error": "invalid_connection"} + }, + { + "id": "routing-missing-environment-global-no-fallthrough", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "environment": {"DETERMA_CONNECTION": "missing"}, + "selected_context": "selected" + }, + "expect": {"error": "invalid_connection"} + }, + { + "id": "routing-invalid-environment-type", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "environment": {"DETERMA_CONNECTION": null}, + "selected_context": "selected" + }, + "expect": {"error": "invalid_environment"} + }, + { + "id": "routing-selected-context-exact", + "configuration": "all-levels", + "request": {"resource": "state/foo", "selected_context": "selected"}, + "expect": {"value": "context-exact"} + }, + { + "id": "routing-selected-context-product", + "configuration": "all-levels", + "request": {"resource": "state/other", "selected_context": "selected"}, + "expect": {"value": "context-product"} + }, + { + "id": "routing-invalid-selected-context-no-fallthrough", + "configuration": "all-levels", + "request": {"resource": "state/foo", "selected_context": "missing"}, + "expect": {"error": "invalid_context"} + }, + { + "id": "routing-null-selected-context-no-fallthrough", + "configuration": "all-levels", + "request": {"resource": "state/foo", "selected_context": null}, + "expect": {"error": "invalid_context"} + }, + { + "id": "routing-empty-selected-context-falls-to-defaults", + "configuration": "all-levels", + "request": {"resource": "state/foo", "selected_context": "empty"}, + "expect": {"value": "default-exact"} + }, + { + "id": "routing-defaults-exact-without-selected-context", + "configuration": "all-levels", + "request": {"resource": "state/foo"}, + "expect": {"value": "default-exact"} + }, + { + "id": "routing-defaults-product", + "configuration": "all-levels", + "request": {"resource": "state/other", "selected_context": "empty"}, + "expect": {"value": "default-product"} + }, + { + "id": "routing-global-context-exact", + "configuration": "global-only", + "request": {"resource": "state/foo"}, + "expect": {"value": "global-exact"} + }, + { + "id": "routing-global-context-product", + "configuration": "global-only", + "request": {"resource": "state/other"}, + "expect": {"value": "global-product"} + }, + { + "id": "routing-empty-selected-context-falls-to-global-context", + "configuration": "global-only", + "request": {"resource": "state/foo", "selected_context": "empty"}, + "expect": {"value": "global-exact"} + }, + { + "id": "routing-exact-resource-before-product", + "configuration": "all-levels", + "request": {"resource": "state/foo", "selected_context": "selected"}, + "expect": {"value": "context-exact"} + }, + { + "id": "routing-intermediate-prefix-does-not-match", + "configuration": "all-levels", + "request": {"resource": "state/foo/bar", "selected_context": "selected"}, + "expect": {"value": "context-product"} + }, + { + "id": "routing-legacy-flattened-environment-name-ignored", + "configuration": "all-levels", + "request": { + "resource": "state/foo", + "environment": {"DETERMA_STATE_FOO_CONNECTION": "environment-exact"}, + "selected_context": "selected" + }, + "expect": {"value": "context-exact"} + }, + { + "id": "routing-product-only-environment", + "configuration": "all-levels", + "request": { + "resource": "state", + "environment": {"DETERMA_STATE_CONNECTION": "environment-product"}, + "selected_context": "selected" + }, + "expect": {"value": "environment-product"} + }, + { + "id": "routing-no-context-no-default-unresolved", + "configuration": "no-fallback", + "request": {"resource": "state/foo"}, + "expect": {"error": "unresolved_connection"} + }, + { + "id": "routing-invalid-resource", + "configuration": "no-fallback", + "request": {"resource": "state/foo_bar"}, + "expect": {"error": "invalid_name"} + } + ] +} diff --git a/scripts/requirements-family-connection-v1-vectors.txt b/scripts/requirements-family-connection-v1-vectors.txt new file mode 100644 index 0000000..85dcf87 --- /dev/null +++ b/scripts/requirements-family-connection-v1-vectors.txt @@ -0,0 +1,2 @@ +idna==3.7 \ + --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 diff --git a/scripts/validate-family-connection-v1-vectors.py b/scripts/validate-family-connection-v1-vectors.py new file mode 100755 index 0000000..533675c --- /dev/null +++ b/scripts/validate-family-connection-v1-vectors.py @@ -0,0 +1,730 @@ +#!/usr/bin/env python3 +"""Validate the language-neutral Family Connection/Context v1 vectors.""" + +from __future__ import annotations + +import argparse +import bisect +import ipaddress +import json +import re +import sys +import unicodedata +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import idna +from idna import idnadata, uts46data +from idna.core import valid_contextj + +ROOT = Path(__file__).resolve().parents[1] +VECTOR_ROOT = ROOT / "conformance" / "family-connection-v1" +NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$") +IPV4_RE = re.compile(r"^(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){3}$") +IPV4_LIKE_RE = re.compile( + r"^(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*$", + re.IGNORECASE, +) +ENDPOINT_RE = re.compile( + r"^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^\/?#]*)([^?#]*)$" +) +PCHAR = frozenset( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@" +) +UNRESERVED = frozenset( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" +) +HEX = frozenset("0123456789abcdefABCDEF") +EXPECTED_UNICODE_VERSION = "15.1.0" +ERROR_CODES = frozenset( + { + "duplicate_key", + "insecure_endpoint", + "invalid_connection", + "invalid_context", + "invalid_endpoint_authority", + "invalid_endpoint_characters", + "invalid_endpoint_host", + "invalid_endpoint_path", + "invalid_endpoint_port", + "invalid_endpoint_syntax", + "invalid_endpoint_type", + "invalid_environment", + "invalid_name", + "invalid_reference", + "invalid_source", + "invalid_type", + "invalid_version", + "missing_field", + "unknown_field", + "unresolved_connection", + "unsupported_endpoint_scheme", + } +) + + +class VectorError(Exception): + def __init__(self, code: str): + super().__init__(code) + self.code = code + + +@dataclass(frozen=True) +class IntegerToken: + source: str + + +@dataclass(frozen=True) +class NonIntegerNumberToken: + source: str + + +def reject_duplicate_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise VectorError("duplicate_key") + result[key] = value + return result + + +def reject_nonfinite_constant(_value: str) -> Any: + raise VectorError("invalid_source") + + +def parse_configuration_source(source: Any) -> dict[str, Any]: + if not isinstance(source, str): + raise VectorError("invalid_source") + try: + value = json.loads( + source, + object_pairs_hook=reject_duplicate_pairs, + parse_int=IntegerToken, + parse_float=NonIntegerNumberToken, + parse_constant=reject_nonfinite_constant, + ) + except VectorError: + raise + except (json.JSONDecodeError, UnicodeError): + raise VectorError("invalid_source") from None + return validate_configuration(value) + + +def require_closed_object( + value: Any, required: set[str], optional: set[str] +) -> dict[str, Any]: + if not isinstance(value, dict): + raise VectorError("invalid_type") + if not required.issubset(value): + raise VectorError("missing_field") + if set(value) - required - optional: + raise VectorError("unknown_field") + return value + + +def require_name(value: Any) -> str: + if not isinstance(value, str): + raise VectorError("invalid_type") + if not NAME_RE.fullmatch(value): + raise VectorError("invalid_name") + return value + + +def require_nonempty_string(value: Any) -> str: + if not isinstance(value, str): + raise VectorError("invalid_type") + if not value: + raise VectorError("invalid_name") + return value + + +def validate_routes(value: Any, connection_names: set[str]) -> dict[str, str]: + if not isinstance(value, dict): + raise VectorError("invalid_type") + result: dict[str, str] = {} + for resource, connection in value.items(): + validate_resource(resource) + if not isinstance(connection, str): + raise VectorError("invalid_type") + if connection not in connection_names: + raise VectorError("invalid_reference") + result[resource] = connection + return result + + +def validate_configuration(value: Any) -> dict[str, Any]: + root = require_closed_object( + value, + {"version", "connections", "contexts"}, + {"default_context", "defaults"}, + ) + version = root["version"] + if not isinstance(version, IntegerToken) or version.source != "1": + raise VectorError("invalid_version") + + raw_connections = root["connections"] + if not isinstance(raw_connections, dict): + raise VectorError("invalid_type") + connections: dict[str, Any] = {} + for raw_name, raw_connection in raw_connections.items(): + name = require_name(raw_name) + connection = require_closed_object( + raw_connection, {"endpoint"}, {"credential_ref"} + ) + endpoint = canonicalize_endpoint(connection["endpoint"]) + normalized: dict[str, Any] = {"endpoint": endpoint} + if "credential_ref" in connection: + credential = require_closed_object( + connection["credential_ref"], {"provider", "name"}, set() + ) + normalized["credential_ref"] = { + "provider": require_name(credential["provider"]), + "name": require_nonempty_string(credential["name"]), + } + connections[name] = normalized + + connection_names = set(connections) + raw_contexts = root["contexts"] + if not isinstance(raw_contexts, dict): + raise VectorError("invalid_type") + contexts: dict[str, Any] = {} + for raw_name, raw_context in raw_contexts.items(): + name = require_name(raw_name) + context = require_closed_object(raw_context, {"routes"}, set()) + contexts[name] = { + "routes": validate_routes(context["routes"], connection_names) + } + + normalized_root: dict[str, Any] = { + "version": 1, + "connections": connections, + "contexts": contexts, + } + if "default_context" in root: + default_context = require_name(root["default_context"]) + if default_context not in contexts: + raise VectorError("invalid_reference") + normalized_root["default_context"] = default_context + if "defaults" in root: + defaults = require_closed_object(root["defaults"], {"routes"}, set()) + normalized_root["defaults"] = { + "routes": validate_routes(defaults["routes"], connection_names) + } + return normalized_root + + +def validate_resource(value: Any) -> list[str]: + if not isinstance(value, str): + raise VectorError("invalid_type") + segments = value.split("/") + if not segments or any(not NAME_RE.fullmatch(segment) for segment in segments): + raise VectorError("invalid_name") + return segments + + +def uts46_status(codepoint: int) -> str: + table = uts46data.uts46data + row = table[ + codepoint + if codepoint < 256 + else bisect.bisect_left(table, (codepoint, "Z")) - 1 + ] + return row[1] + + +def validate_uts46_label(label: str) -> None: + if not label or not unicodedata.is_normalized("NFC", label): + raise idna.IDNAError("label must be non-empty NFC") + if label.startswith("-") or label.endswith("-") or label[2:4] == "--": + raise idna.IDNAError("label has disallowed hyphens") + if "." in label or unicodedata.category(label[0]).startswith("M"): + raise idna.IDNAError("label has invalid structure") + for position, character in enumerate(label): + if uts46_status(ord(character)) not in {"V", "D"}: + raise idna.IDNAError("label contains a disallowed code point") + if character in {"\u200c", "\u200d"} and not valid_contextj( + label, position + ): + raise idna.IDNAError("label fails ContextJ") + + +def encode_uts46_label(label: str) -> tuple[str, str]: + if label.startswith("xn--"): + if any(ord(character) > 0x7F for character in label): + raise idna.IDNAError("Punycode label must be ASCII") + try: + unicode_label = label[4:].encode("ascii").decode("punycode") + except UnicodeError: + raise idna.IDNAError("invalid Punycode label") from None + else: + unicode_label = label + validate_uts46_label(unicode_label) + if all(ord(character) < 0x80 for character in unicode_label): + ascii_label = unicode_label + else: + ascii_label = "xn--" + unicode_label.encode("punycode").decode("ascii") + if len(ascii_label.encode("ascii")) > 63: + raise idna.IDNAError("label exceeds DNS length") + return unicode_label, ascii_label + + +def domain_to_ascii(host: str) -> str: + try: + mapped = idna.uts46_remap( + host, std3_rules=True, transitional=False + ) + source_labels = mapped.split(".") + trailing_dot = bool(source_labels and source_labels[-1] == "") + if trailing_dot: + source_labels.pop() + if not source_labels: + raise idna.IDNAError("empty domain") + encoded = [encode_uts46_label(label) for label in source_labels] + unicode_labels = [label for label, _ascii_label in encoded] + bidi_domain = any( + unicodedata.bidirectional(character) in {"R", "AL", "AN"} + for label in unicode_labels + for character in label + ) + if bidi_domain: + for label in unicode_labels: + idna.check_bidi(label, check_ltr=True) + ascii_domain = ".".join(label for _unicode_label, label in encoded) + if trailing_dot: + ascii_domain += "." + maximum_length = 254 if trailing_dot else 253 + if len(ascii_domain.encode("ascii")) > maximum_length: + raise idna.IDNAError("domain exceeds DNS length") + return ascii_domain + except (idna.IDNAError, UnicodeError): + return "" + + +def validate_unicode_data_version() -> None: + versions = {idnadata.__version__, uts46data.__version__} + if versions != {EXPECTED_UNICODE_VERSION}: + raise RuntimeError( + "IDNA tables must both use Unicode " + f"{EXPECTED_UNICODE_VERSION}, got {sorted(versions)}" + ) + runtime_version = tuple( + int(part) for part in unicodedata.unidata_version.split(".") + ) + if runtime_version < (15, 1, 0): + raise RuntimeError( + "runtime Unicode data must be at least 15.1.0, got " + f"{unicodedata.unidata_version}" + ) + + +def canonicalize_ipv6(address: ipaddress.IPv6Address) -> str: + number = int(address) + groups = [f"{(number >> shift) & 0xFFFF:x}" for shift in range(112, -1, -16)] + best_start = -1 + best_length = 0 + index = 0 + while index < len(groups): + if groups[index] != "0": + index += 1 + continue + end = index + while end < len(groups) and groups[end] == "0": + end += 1 + length = end - index + if length >= 2 and length > best_length: + best_start = index + best_length = length + index = end + if best_start < 0: + return ":".join(groups) + left = ":".join(groups[:best_start]) + right = ":".join(groups[best_start + best_length :]) + if left and right: + return f"{left}::{right}" + if left: + return f"{left}::" + if right: + return f"::{right}" + return "::" + + +def canonicalize_host(raw_host: str, bracketed: bool) -> tuple[str, str, Any]: + if not raw_host or "%" in raw_host: + raise VectorError("invalid_endpoint_host") + if bracketed: + try: + address = ipaddress.IPv6Address(raw_host) + except ipaddress.AddressValueError: + raise VectorError("invalid_endpoint_host") from None + canonical = canonicalize_ipv6(address) + return f"[{canonical}]", "ipv6", address + + if IPV4_RE.fullmatch(raw_host): + octets = raw_host.split(".") + if any(int(octet) > 255 for octet in octets): + raise VectorError("invalid_endpoint_host") + canonical = ".".join(str(int(octet)) for octet in octets) + return canonical, "ipv4", ipaddress.IPv4Address(canonical) + if IPV4_LIKE_RE.fullmatch(raw_host): + raise VectorError("invalid_endpoint_host") + + ascii_host = domain_to_ascii(raw_host) + if not ascii_host or ascii_host.endswith("."): + raise VectorError("invalid_endpoint_host") + labels = ascii_host.lower().split(".") + if any( + not label + or len(label) > 63 + or not re.fullmatch(r"[a-z0-9-]+", label) + or label.startswith("-") + or label.endswith("-") + for label in labels + ): + raise VectorError("invalid_endpoint_host") + canonical = ".".join(labels) + if len(canonical) > 253: + raise VectorError("invalid_endpoint_host") + return canonical, "registered", canonical + + +def normalize_path(raw_path: str) -> str: + output: list[str] = [] + index = 0 + while index < len(raw_path): + character = raw_path[index] + codepoint = ord(character) + if codepoint > 0x7F: + raise VectorError("invalid_endpoint_path") + if character == "%": + if ( + index + 2 >= len(raw_path) + or raw_path[index + 1] not in HEX + or raw_path[index + 2] not in HEX + ): + raise VectorError("invalid_endpoint_path") + octet = int(raw_path[index + 1 : index + 3], 16) + if octet >= 0x80 or octet <= 0x1F or octet in {0x7F, 0x2F, 0x5C}: + raise VectorError("invalid_endpoint_path") + decoded = chr(octet) + output.append(decoded if decoded in UNRESERVED else f"%{octet:02X}") + index += 3 + continue + if character != "/" and character not in PCHAR: + raise VectorError("invalid_endpoint_path") + output.append(character) + index += 1 + return remove_dot_segments("".join(output)) + + +def remove_last_segment(path: str) -> str: + slash = path.rfind("/") + return "" if slash < 0 else path[:slash] + + +def remove_dot_segments(path: str) -> str: + source = path + output = "" + while source: + if source.startswith("../"): + source = source[3:] + elif source.startswith("./"): + source = source[2:] + elif source.startswith("/./"): + source = "/" + source[3:] + elif source == "/.": + source = "/" + elif source.startswith("/../"): + source = "/" + source[4:] + output = remove_last_segment(output) + elif source == "/..": + source = "/" + output = remove_last_segment(output) + elif source in {".", ".."}: + source = "" + else: + start = 1 if source.startswith("/") else 0 + slash = source.find("/", start) + if slash < 0: + output += source + source = "" + else: + output += source[:slash] + source = source[slash:] + return output + + +def canonicalize_endpoint(value: Any) -> str: + if not isinstance(value, str): + raise VectorError("invalid_endpoint_type") + if not value: + raise VectorError("invalid_endpoint_syntax") + if any( + character == "\\" + or character == " " + or ord(character) <= 0x1F + or ord(character) == 0x7F + or 0xD800 <= ord(character) <= 0xDFFF + for character in value + ): + raise VectorError("invalid_endpoint_characters") + match = ENDPOINT_RE.fullmatch(value) + if not match: + raise VectorError("invalid_endpoint_syntax") + scheme, authority, raw_path = match.groups() + scheme = scheme.lower() + if scheme not in {"http", "https"}: + raise VectorError("unsupported_endpoint_scheme") + if not authority or "@" in authority: + raise VectorError("invalid_endpoint_authority") + + bracketed = authority.startswith("[") + raw_port: Any = None + if bracketed: + close = authority.find("]") + if close < 0: + raise VectorError("invalid_endpoint_authority") + raw_host = authority[1:close] + remainder = authority[close + 1 :] + if remainder: + if not remainder.startswith(":"): + raise VectorError("invalid_endpoint_authority") + raw_port = remainder[1:] + else: + if "[" in authority or "]" in authority or authority.count(":") > 1: + raise VectorError("invalid_endpoint_authority") + if ":" in authority: + raw_host, raw_port = authority.rsplit(":", 1) + else: + raw_host = authority + + canonical_host, host_kind, host_value = canonicalize_host(raw_host, bracketed) + canonical_port = "" + if raw_port is not None: + if not re.fullmatch(r"[1-9][0-9]*", raw_port): + raise VectorError("invalid_endpoint_port") + port = int(raw_port) + if port > 65535: + raise VectorError("invalid_endpoint_port") + if not ((scheme == "https" and port == 443) or (scheme == "http" and port == 80)): + canonical_port = f":{port}" + + path = normalize_path(raw_path) + if not path: + path = "/" + elif path != "/": + path = path.rstrip("/") or "/" + + if scheme == "http": + loopback = ( + (host_kind == "registered" and host_value == "localhost") + or (host_kind == "ipv4" and host_value.is_loopback) + or (host_kind == "ipv6" and host_value == ipaddress.IPv6Address("::1")) + ) + if not loopback: + raise VectorError("insecure_endpoint") + return f"{scheme}://{canonical_host}{canonical_port}{path}" + + +def environment_name(resource: Any) -> str: + segments = validate_resource(resource) + + def encode(segment: str) -> str: + return "".join("_H" if character == "-" else character.upper() for character in segment) + + return f"DETERMA_{'__'.join(encode(segment) for segment in segments)}_CONNECTION" + + +def route_keys(resource: str) -> list[str]: + product = resource.split("/", 1)[0] + return [resource] if product == resource else [resource, product] + + +def first_route(routes: dict[str, str], resource: str) -> Any: + for key in route_keys(resource): + if key in routes: + return routes[key] + return None + + +def resolve_connection( + configuration: dict[str, Any], request: dict[str, Any] +) -> str: + resource = request.get("resource") + validate_resource(resource) + connections = configuration["connections"] + + if "explicit_connection" in request: + explicit = request["explicit_connection"] + if not isinstance(explicit, str) or explicit not in connections: + raise VectorError("invalid_connection") + return explicit + + environment = request.get("environment", {}) + if not isinstance(environment, dict) or any( + not isinstance(key, str) or not isinstance(value, str) + for key, value in environment.items() + ): + raise VectorError("invalid_environment") + environment_keys = [environment_name(resource)] + product = resource.split("/", 1)[0] + product_key = environment_name(product) + if product_key not in environment_keys: + environment_keys.append(product_key) + environment_keys.append("DETERMA_CONNECTION") + for key in environment_keys: + if key in environment: + connection = environment[key] + if not connection or connection not in connections: + raise VectorError("invalid_connection") + return connection + + if "selected_context" in request: + selected = request["selected_context"] + if not isinstance(selected, str) or selected not in configuration["contexts"]: + raise VectorError("invalid_context") + connection = first_route(configuration["contexts"][selected]["routes"], resource) + if connection is not None: + return connection + + defaults = configuration.get("defaults", {"routes": {}}) + connection = first_route(defaults["routes"], resource) + if connection is not None: + return connection + + default_context = configuration.get("default_context") + if default_context is not None: + connection = first_route( + configuration["contexts"][default_context]["routes"], resource + ) + if connection is not None: + return connection + raise VectorError("unresolved_connection") + + +def load_fixture(name: str) -> dict[str, Any]: + path = VECTOR_ROOT / name + with path.open(encoding="utf-8") as fixture_file: + return json.load(fixture_file, object_pairs_hook=reject_duplicate_pairs) + + +def assert_expected(case: dict[str, Any], actual: Any = None, error: Any = None) -> None: + expected = case["expect"] + if not isinstance(case.get("id"), str) or not case["id"]: + raise AssertionError("every vector must have a non-empty string id") + if set(expected) not in ({"value"}, {"error"}): + raise AssertionError(f"{case['id']}: expect must contain exactly value or error") + if "error" in expected and expected["error"] not in ERROR_CODES: + raise AssertionError(f"{case['id']}: unknown error code {expected['error']!r}") + if "error" in expected: + if error != expected["error"]: + raise AssertionError( + f"{case['id']}: expected error {expected['error']!r}, got {error!r}" + ) + elif error is not None: + raise AssertionError(f"{case['id']}: unexpected error {error!r}") + elif actual != expected["value"]: + raise AssertionError( + f"{case['id']}: expected {expected['value']!r}, got {actual!r}" + ) + + +def run_case(case: dict[str, Any], operation: Any) -> None: + try: + actual = operation() + except VectorError as error: + assert_expected(case, error=error.code) + else: + assert_expected(case, actual=actual) + + +def validate_fixture_header(fixture: dict[str, Any], suite: str) -> None: + if fixture.get("contract") != "family-connection-context-v1": + raise AssertionError(f"{suite}: incorrect contract identifier") + if fixture.get("suite") != suite: + raise AssertionError(f"{suite}: incorrect suite identifier") + if not isinstance(fixture.get("cases"), list): + raise TypeError(f"{suite}: cases must be an array") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--verbose", action="store_true") + arguments = parser.parse_args() + validate_unicode_data_version() + seen_ids: set[str] = set() + passed = 0 + + configuration_fixture = load_fixture("configuration.json") + validate_fixture_header(configuration_fixture, "configuration") + for case in configuration_fixture["cases"]: + run_case(case, lambda case=case: parse_configuration_source(case["source"])) + if case["id"] in seen_ids: + raise AssertionError(f"duplicate case id: {case['id']}") + seen_ids.add(case["id"]) + passed += 1 + + endpoint_fixture = load_fixture("endpoints.json") + validate_fixture_header(endpoint_fixture, "endpoints") + for case in endpoint_fixture["cases"]: + run_case(case, lambda case=case: canonicalize_endpoint(case["input"])) + if case["id"] in seen_ids: + raise AssertionError(f"duplicate case id: {case['id']}") + seen_ids.add(case["id"]) + passed += 1 + + environment_fixture = load_fixture("environment.json") + validate_fixture_header(environment_fixture, "environment") + environment_results: dict[str, str] = {} + for case in environment_fixture["cases"]: + run_case(case, lambda case=case: environment_name(case["resource"])) + if "value" in case["expect"]: + environment_results[case["id"]] = case["expect"]["value"] + if case["id"] in seen_ids: + raise AssertionError(f"duplicate case id: {case['id']}") + seen_ids.add(case["id"]) + passed += 1 + for distinct_set in environment_fixture.get("distinct_sets", []): + values = [environment_results[case_id] for case_id in distinct_set] + if len(values) != len(set(values)): + raise AssertionError(f"environment names collide: {distinct_set}") + + routing_fixture = load_fixture("routing.json") + validate_fixture_header(routing_fixture, "routing") + configurations = { + name: parse_configuration_source(source) + for name, source in routing_fixture["configurations"].items() + } + for case in routing_fixture["cases"]: + configuration = configurations[case["configuration"]] + run_case( + case, + lambda case=case, configuration=configuration: resolve_connection( + configuration, case["request"] + ), + ) + if case["id"] in seen_ids: + raise AssertionError(f"duplicate case id: {case['id']}") + seen_ids.add(case["id"]) + passed += 1 + + if arguments.verbose: + for case_id in sorted(seen_ids): + print(f"PASS {case_id}") + print(f"Family Connection/Context v1 vectors: {passed} passed") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ( + AssertionError, + json.JSONDecodeError, + KeyError, + RuntimeError, + TypeError, + VectorError, + ) as error: + print(f"vector validation failed: {error}", file=sys.stderr) + raise SystemExit(1) from error