From be473b1cc0eba226398ddd2363e66a9a38d0600f Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Mon, 17 Aug 2026 02:10:04 +0900 Subject: [PATCH 1/5] Implement family connection context v1 resolver --- .github/workflows/ci.yml | 3 +- .gitignore | 1 + AGENTS.md | 2 +- README.md | 8 +- docs/family-connection-context-v1.md | 33 +- node/README.md | 3 + node/bin/determa.js | 5 + node/lib/family-connection-context-v1.js | 679 +++++++++++++++ node/package-lock.json | 43 + node/package.json | 36 +- node/test/dispatch.test.js | 14 + .../test/family-connection-context-v1.test.js | 59 ++ python/README.md | 2 + python/pyproject.toml | 6 +- python/src/determa/_cli.py | 6 + .../determa/family_connection_context_v1.py | 565 ++++++++++++ python/tests/test_cli.py | 16 + .../test_family_connection_context_v1.py | 83 ++ rust/Cargo.lock | 508 +++++++++++ rust/Cargo.toml | 11 + rust/README.md | 9 +- rust/src/family_connection_context_v1.rs | 809 ++++++++++++++++++ rust/src/lib.rs | 1 + rust/src/main.rs | 6 + rust/tests/dispatch.rs | 41 +- rust/tests/family_connection_context_v1.rs | 100 +++ ...uirements-family-connection-v1-vectors.txt | 4 +- 27 files changed, 3020 insertions(+), 33 deletions(-) create mode 100644 node/lib/family-connection-context-v1.js create mode 100644 node/package-lock.json create mode 100644 node/test/family-connection-context-v1.test.js create mode 100644 python/src/determa/family_connection_context_v1.py create mode 100644 python/tests/test_family_connection_context_v1.py create mode 100644 rust/Cargo.lock create mode 100644 rust/src/family_connection_context_v1.rs create mode 100644 rust/src/lib.rs create mode 100644 rust/tests/family_connection_context_v1.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2efdde5..fe048f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,5 +66,6 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" + - run: npm ci - name: Tests - run: node test/dispatch.test.js + run: npm test diff --git a/.gitignore b/.gitignore index 766f522..fb7a06b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ dist/ # Rust /rust/target/ Cargo.lock +!/rust/Cargo.lock # Node node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 957980a..bb29ed8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ cd python && pip install -e '.[dev]' && ruff check . && pytest -q # rust cd rust && cargo build --release && cargo clippy --release --all-targets -- -D warnings && cargo test # node -cd node && node test/dispatch.test.js +cd node && npm ci && npm test ``` Note: unlike `determa-state-rust`, the rust launcher **does** enforce `clippy -D warnings` in CI — keep it clean. diff --git a/README.md b/README.md index 9183d17..a868787 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,11 @@ change needed, they just have to be on `PATH` as `determa-`. ## Family configuration [Family Connection and Context Configuration v1](docs/family-connection-context-v1.md) -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. +defines the language-neutral connection, context, endpoint-routing, and future +family command contract. The current packages include local resolver APIs and +reserve `determa config`, `determa context`, and `determa auth` before product +dispatch, but they do not yet implement remote connections, clients, credential +storage, or configuration-file discovery. The machine-readable [v1 conformance vectors](conformance/family-connection-v1/) fix the expected configuration, endpoint, environment-name, and routing results diff --git a/docs/family-connection-context-v1.md b/docs/family-connection-context-v1.md index 0b533b7..8986d1b 100644 --- a/docs/family-connection-context-v1.md +++ b/docs/family-connection-context-v1.md @@ -10,10 +10,14 @@ implemented. The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, **SHOULD NOT**, and **MAY** are to be interpreted as described in RFC 2119 and RFC 8174. -This is a design contract only. Current Python, Rust, and Node `determa` -launchers do not read this configuration and retain their existing behavior. -It does not define a configuration-file location, credential store, network -protocol, server, client, or State machine/checkpoint format. +Current Python, Rust, and Node `determa` packages implement this contract as +local resolver APIs and reserve the family command names below before product +dispatch. They do not discover or read a configuration file and do not perform +remote transport. This document does not define a configuration-file location, +credential store, network protocol, server, client, or State machine/checkpoint +format. The Rust implementation uses exact behavior-relevant ICU data +dependencies and a checked lockfile because the v1 endpoint profile depends on +the exact Unicode 15.1 UTS #46 boundary. ## Principles @@ -314,15 +318,18 @@ variable for selecting a context. ## Command namespace reservation The family-level command names `config`, `context`, and `auth` are reserved. -No present or future product may claim those names, and each future Python, -Rust, and Node launcher implementation MUST recognize them before product -dispatch. Their commands, flags, output, and persistence behavior are not -implemented or specified here. - -This reservation does not change current launcher behavior. In particular, -this document does not add a parser, help entry, executable command, or -compatibility promise for `determa config`, `determa context`, or `determa auth` -until a later implementation release changes all three launchers together. +No present or future product may claim those names, and each Python, Rust, and +Node launcher implementation MUST recognize them before product dispatch. Their +subcommands, flags, output, and persistence behavior are not implemented or +specified here. + +Until command syntax is specified, invoking `determa config`, `determa context`, +or `determa auth` MUST fail locally before product dispatch with exit status +`2`, empty stdout, and stderr exactly: + +```text +determa: family command '' is reserved but not implemented yet. +``` ## Local implementation selection and State storage diff --git a/node/README.md b/node/README.md index 51d4b3d..5f565fd 100644 --- a/node/README.md +++ b/node/README.md @@ -15,6 +15,9 @@ $ determa --version It is language-agnostic: it dispatches to whichever `determa-state` is on `PATH`, be it the Node, Python, or Rust build. +The package also exposes the Family Connection/Context v1 resolver APIs. Node +18 or newer is required by the pinned UTS #46 dependency. + ## License MIT diff --git a/node/bin/determa.js b/node/bin/determa.js index bb4df89..0d76108 100644 --- a/node/bin/determa.js +++ b/node/bin/determa.js @@ -11,6 +11,7 @@ const { spawnSync } = require("child_process"); const fs = require("fs"); const path = require("path"); +const { RESERVED_FAMILY_COMMANDS } = require("../lib/family-connection-context-v1"); const PREFIX = "determa-"; const VERSION = require("../package.json").version; @@ -150,6 +151,10 @@ function main(argv) { discover().forEach(([p, i]) => console.log(formatProduct(p, i))); return 0; } + if (RESERVED_FAMILY_COMMANDS.has(cmd)) { + process.stderr.write(`determa: family command '${cmd}' is reserved but not implemented yet.\n`); + return 2; + } const exe = exeFor(cmd); if (!exe) { process.stderr.write( diff --git a/node/lib/family-connection-context-v1.js b/node/lib/family-connection-context-v1.js new file mode 100644 index 0000000..75173c7 --- /dev/null +++ b/node/lib/family-connection-context-v1.js @@ -0,0 +1,679 @@ +"use strict"; + +const tr46 = require("tr46"); + +const NAME_RE = /^[a-z][a-z0-9-]*$/; +const IPV4_RE = /^(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){3}$/; +const IPV4_LIKE_RE = /^(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*$/i; +const ENDPOINT_RE = /^([A-Za-z][A-Za-z0-9+.-]*):\/\/([^/?#]*)([^?#]*)$/u; +const PCHAR = new Set( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$&'()*+,;=:@" +); +const UNRESERVED = new Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"); +const RESERVED_FAMILY_COMMANDS = new Set(["auth", "config", "context"]); + +class FamilyConnectionError extends Error { + constructor(code) { + super(code); + this.code = code; + } +} + +class IntegerToken { + constructor(source) { + this.source = source; + } +} + +class NumberToken { + constructor(source) { + this.source = source; + } +} + +function fail(code) { + throw new FamilyConnectionError(code); +} + +class JsonParser { + constructor(source) { + this.source = source; + this.index = 0; + } + + parse() { + const value = this.parseValue(); + this.skipWhitespace(); + if (this.index !== this.source.length) fail("invalid_source"); + return value; + } + + skipWhitespace() { + while (/[\t\n\r ]/.test(this.source[this.index] || "")) this.index++; + } + + parseValue() { + this.skipWhitespace(); + const character = this.source[this.index]; + if (character === "{") return this.parseObject(); + if (character === "[") return this.parseArray(); + if (character === '"') return this.parseString(); + if (character === "t") return this.parseLiteral("true", true); + if (character === "f") return this.parseLiteral("false", false); + if (character === "n") return this.parseLiteral("null", null); + if (character === "-" || (character >= "0" && character <= "9")) return this.parseNumber(); + fail("invalid_source"); + } + + parseLiteral(source, value) { + if (this.source.slice(this.index, this.index + source.length) !== source) fail("invalid_source"); + this.index += source.length; + return value; + } + + parseObject() { + this.index++; + const result = {}; + const seen = new Set(); + this.skipWhitespace(); + if (this.source[this.index] === "}") { + this.index++; + return result; + } + while (true) { + this.skipWhitespace(); + if (this.source[this.index] !== '"') fail("invalid_source"); + const key = this.parseString(); + if (seen.has(key)) fail("duplicate_key"); + seen.add(key); + this.skipWhitespace(); + if (this.source[this.index] !== ":") fail("invalid_source"); + this.index++; + result[key] = this.parseValue(); + this.skipWhitespace(); + if (this.source[this.index] === "}") { + this.index++; + return result; + } + if (this.source[this.index] !== ",") fail("invalid_source"); + this.index++; + } + } + + parseArray() { + this.index++; + const result = []; + this.skipWhitespace(); + if (this.source[this.index] === "]") { + this.index++; + return result; + } + while (true) { + result.push(this.parseValue()); + this.skipWhitespace(); + if (this.source[this.index] === "]") { + this.index++; + return result; + } + if (this.source[this.index] !== ",") fail("invalid_source"); + this.index++; + } + } + + parseString() { + this.index++; + let result = ""; + while (this.index < this.source.length) { + const character = this.source[this.index++]; + if (character === '"') return result; + if (character === "\\") { + result += this.parseEscape(); + continue; + } + if (character.charCodeAt(0) <= 0x1f) fail("invalid_source"); + const code = character.charCodeAt(0); + if (code >= 0xd800 && code <= 0xdbff) { + const next = this.source.charCodeAt(this.index); + if (!(next >= 0xdc00 && next <= 0xdfff)) fail("invalid_source"); + result += String.fromCodePoint(0x10000 + ((code - 0xd800) << 10) + (next - 0xdc00)); + this.index++; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) fail("invalid_source"); + result += character; + } + fail("invalid_source"); + } + + parseEscape() { + const escape = this.source[this.index++]; + switch (escape) { + case '"': + case "\\": + case "/": + return escape; + case "b": + return "\b"; + case "f": + return "\f"; + case "n": + return "\n"; + case "r": + return "\r"; + case "t": + return "\t"; + case "u": + return this.parseUnicodeEscape(); + default: + fail("invalid_source"); + } + } + + parseHexCodeUnit() { + const hex = this.source.slice(this.index, this.index + 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail("invalid_source"); + this.index += 4; + return parseInt(hex, 16); + } + + parseUnicodeEscape() { + const first = this.parseHexCodeUnit(); + if (first >= 0xd800 && first <= 0xdbff) { + if (this.source[this.index] !== "\\" || this.source[this.index + 1] !== "u") { + fail("invalid_source"); + } + this.index += 2; + const second = this.parseHexCodeUnit(); + if (second < 0xdc00 || second > 0xdfff) fail("invalid_source"); + return String.fromCodePoint(0x10000 + ((first - 0xd800) << 10) + (second - 0xdc00)); + } + if (first >= 0xdc00 && first <= 0xdfff) fail("invalid_source"); + return String.fromCharCode(first); + } + + parseNumber() { + const start = this.index; + if (this.source[this.index] === "-") this.index++; + if (this.source[this.index] === "0") { + this.index++; + } else if (this.source[this.index] >= "1" && this.source[this.index] <= "9") { + while (this.source[this.index] >= "0" && this.source[this.index] <= "9") this.index++; + } else { + fail("invalid_source"); + } + let integer = true; + if (this.source[this.index] === ".") { + integer = false; + this.index++; + if (!(this.source[this.index] >= "0" && this.source[this.index] <= "9")) fail("invalid_source"); + while (this.source[this.index] >= "0" && this.source[this.index] <= "9") this.index++; + } + if (this.source[this.index] === "e" || this.source[this.index] === "E") { + integer = false; + this.index++; + if (this.source[this.index] === "+" || this.source[this.index] === "-") this.index++; + if (!(this.source[this.index] >= "0" && this.source[this.index] <= "9")) fail("invalid_source"); + while (this.source[this.index] >= "0" && this.source[this.index] <= "9") this.index++; + } + const source = this.source.slice(start, this.index); + return integer ? new IntegerToken(source) : new NumberToken(source); + } +} + +function parseConfigurationSource(source) { + if (typeof source !== "string") fail("invalid_source"); + return validateConfiguration(new JsonParser(source).parse()); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function requireClosedObject(value, required, optional) { + if (!isObject(value) || value instanceof IntegerToken || value instanceof NumberToken) { + fail("invalid_type"); + } + for (const field of required) { + if (!Object.prototype.hasOwnProperty.call(value, field)) fail("missing_field"); + } + for (const field of Object.keys(value)) { + if (!required.has(field) && !optional.has(field)) fail("unknown_field"); + } + return value; +} + +function requireName(value) { + if (typeof value !== "string") fail("invalid_type"); + if (!NAME_RE.test(value)) fail("invalid_name"); + return value; +} + +function requireNonemptyString(value) { + if (typeof value !== "string") fail("invalid_type"); + if (!value) fail("invalid_name"); + return value; +} + +function validateResource(value) { + if (typeof value !== "string") fail("invalid_type"); + const segments = value.split("/"); + if (segments.length === 0 || segments.some(segment => !NAME_RE.test(segment))) { + fail("invalid_name"); + } + return segments; +} + +function validateRoutes(value, connectionNames) { + if (!isObject(value) || value instanceof IntegerToken || value instanceof NumberToken) { + fail("invalid_type"); + } + const result = {}; + for (const [resource, connection] of Object.entries(value)) { + validateResource(resource); + if (typeof connection !== "string") fail("invalid_type"); + if (!connectionNames.has(connection)) fail("invalid_reference"); + result[resource] = connection; + } + return result; +} + +function validateConfiguration(value) { + const root = requireClosedObject( + value, + new Set(["version", "connections", "contexts"]), + new Set(["default_context", "defaults"]) + ); + if (!(root.version instanceof IntegerToken) || root.version.source !== "1") { + fail("invalid_version"); + } + + if (!isObject(root.connections)) fail("invalid_type"); + const connections = {}; + for (const [rawName, rawConnection] of Object.entries(root.connections)) { + const name = requireName(rawName); + const connection = requireClosedObject( + rawConnection, + new Set(["endpoint"]), + new Set(["credential_ref"]) + ); + const normalized = { endpoint: canonicalizeEndpoint(connection.endpoint) }; + if (Object.prototype.hasOwnProperty.call(connection, "credential_ref")) { + const credential = requireClosedObject( + connection.credential_ref, + new Set(["provider", "name"]), + new Set() + ); + normalized.credential_ref = { + provider: requireName(credential.provider), + name: requireNonemptyString(credential.name), + }; + } + connections[name] = normalized; + } + + const connectionNames = new Set(Object.keys(connections)); + if (!isObject(root.contexts)) fail("invalid_type"); + const contexts = {}; + for (const [rawName, rawContext] of Object.entries(root.contexts)) { + const name = requireName(rawName); + const context = requireClosedObject(rawContext, new Set(["routes"]), new Set()); + contexts[name] = { routes: validateRoutes(context.routes, connectionNames) }; + } + + const result = { version: 1, connections, contexts }; + if (Object.prototype.hasOwnProperty.call(root, "default_context")) { + const defaultContext = requireName(root.default_context); + if (!Object.prototype.hasOwnProperty.call(contexts, defaultContext)) fail("invalid_reference"); + result.default_context = defaultContext; + } + if (Object.prototype.hasOwnProperty.call(root, "defaults")) { + const defaults = requireClosedObject(root.defaults, new Set(["routes"]), new Set()); + result.defaults = { routes: validateRoutes(defaults.routes, connectionNames) }; + } + return result; +} + +function assertValidEndpointScalars(value) { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) fail("invalid_endpoint_characters"); + index++; + continue; + } + if (code >= 0xdc00 && code <= 0xdfff) fail("invalid_endpoint_characters"); + if (value[index] === "\\" || value[index] === " " || code <= 0x1f || code === 0x7f) { + fail("invalid_endpoint_characters"); + } + } +} + +function parseStrictIpv4(rawHost) { + if (!IPV4_RE.test(rawHost)) return null; + const octets = rawHost.split(".").map(value => Number(value)); + if (octets.some(octet => octet > 255)) fail("invalid_endpoint_host"); + return octets; +} + +function parseIpv6Part(part) { + if (!/^[0-9a-fA-F]{1,4}$/.test(part)) fail("invalid_endpoint_host"); + return parseInt(part, 16); +} + +function parseIpv6(rawHost) { + if (!rawHost || rawHost.includes("%")) fail("invalid_endpoint_host"); + if ((rawHost.match(/::/g) || []).length > 1) fail("invalid_endpoint_host"); + + const hasCompression = rawHost.includes("::"); + const [leftText, rightText = ""] = rawHost.split("::"); + const parseSide = side => { + if (!side) return []; + return side.split(":").flatMap((part, index, parts) => { + if (!part) fail("invalid_endpoint_host"); + if (part.includes(".")) { + if (index !== parts.length - 1) fail("invalid_endpoint_host"); + const ipv4 = parseStrictIpv4(part); + if (!ipv4) fail("invalid_endpoint_host"); + return [(ipv4[0] << 8) | ipv4[1], (ipv4[2] << 8) | ipv4[3]]; + } + return [parseIpv6Part(part)]; + }); + }; + + const left = parseSide(leftText); + const right = parseSide(rightText); + if (hasCompression) { + const missing = 8 - left.length - right.length; + if (missing < 1) fail("invalid_endpoint_host"); + return [...left, ...Array(missing).fill(0), ...right]; + } + if (left.length !== 8) fail("invalid_endpoint_host"); + return left; +} + +function canonicalizeIpv6(groups) { + let bestStart = -1; + let bestLength = 0; + let index = 0; + while (index < groups.length) { + if (groups[index] !== 0) { + index++; + continue; + } + let end = index; + while (end < groups.length && groups[end] === 0) end++; + const length = end - index; + if (length >= 2 && length > bestLength) { + bestStart = index; + bestLength = length; + } + index = end; + } + const rendered = groups.map(group => group.toString(16)); + if (bestStart < 0) return rendered.join(":"); + const left = rendered.slice(0, bestStart).join(":"); + const right = rendered.slice(bestStart + bestLength).join(":"); + if (left && right) return `${left}::${right}`; + if (left) return `${left}::`; + if (right) return `::${right}`; + return "::"; +} + +function ipv6IsLoopback(groups) { + return groups.slice(0, 7).every(group => group === 0) && groups[7] === 1; +} + +function domainToAscii(host) { + const value = tr46.toASCII(host, { + checkBidi: true, + checkHyphens: true, + checkJoiners: true, + ignoreInvalidPunycode: false, + transitionalProcessing: false, + useSTD3ASCIIRules: true, + verifyDNSLength: true, + }); + return value || ""; +} + +function canonicalizeHost(rawHost, bracketed) { + if (!rawHost || rawHost.includes("%")) fail("invalid_endpoint_host"); + if (bracketed) { + const groups = parseIpv6(rawHost); + return { + canonical: `[${canonicalizeIpv6(groups)}]`, + kind: "ipv6", + value: groups, + }; + } + + const ipv4 = parseStrictIpv4(rawHost); + if (ipv4) { + return { canonical: ipv4.join("."), kind: "ipv4", value: ipv4 }; + } + if (IPV4_LIKE_RE.test(rawHost)) fail("invalid_endpoint_host"); + + const asciiHost = domainToAscii(rawHost); + if (!asciiHost || asciiHost.endsWith(".")) fail("invalid_endpoint_host"); + const labels = asciiHost.toLowerCase().split("."); + if ( + labels.some( + label => + !label || + label.length > 63 || + !/^[a-z0-9-]+$/.test(label) || + label.startsWith("-") || + label.endsWith("-") + ) + ) { + fail("invalid_endpoint_host"); + } + const canonical = labels.join("."); + if (canonical.length > 253) fail("invalid_endpoint_host"); + return { canonical, kind: "registered", value: canonical }; +} + +function normalizePath(rawPath) { + const output = []; + for (let index = 0; index < rawPath.length; ) { + const code = rawPath.codePointAt(index); + const character = String.fromCodePoint(code); + if (code > 0x7f) fail("invalid_endpoint_path"); + if (character === "%") { + const hex = rawPath.slice(index + 1, index + 3); + if (!/^[0-9a-fA-F]{2}$/.test(hex)) fail("invalid_endpoint_path"); + const octet = parseInt(hex, 16); + if (octet >= 0x80 || octet <= 0x1f || octet === 0x7f || octet === 0x2f || octet === 0x5c) { + fail("invalid_endpoint_path"); + } + const decoded = String.fromCharCode(octet); + output.push(UNRESERVED.has(decoded) ? decoded : `%${octet.toString(16).toUpperCase().padStart(2, "0")}`); + index += 3; + continue; + } + if (character !== "/" && !PCHAR.has(character)) fail("invalid_endpoint_path"); + output.push(character); + index += character.length; + } + return removeDotSegments(output.join("")); +} + +function removeLastSegment(path) { + const slash = path.lastIndexOf("/"); + return slash < 0 ? "" : path.slice(0, slash); +} + +function removeDotSegments(path) { + let source = path; + let output = ""; + while (source) { + if (source.startsWith("../")) source = source.slice(3); + else if (source.startsWith("./")) source = source.slice(2); + else if (source.startsWith("/./")) source = "/" + source.slice(3); + else if (source === "/.") source = "/"; + else if (source.startsWith("/../")) { + source = "/" + source.slice(4); + output = removeLastSegment(output); + } else if (source === "/..") { + source = "/"; + output = removeLastSegment(output); + } else if (source === "." || source === "..") source = ""; + else { + const start = source.startsWith("/") ? 1 : 0; + const slash = source.indexOf("/", start); + if (slash < 0) { + output += source; + source = ""; + } else { + output += source.slice(0, slash); + source = source.slice(slash); + } + } + } + return output; +} + +function canonicalizeEndpoint(value) { + if (typeof value !== "string") fail("invalid_endpoint_type"); + if (!value) fail("invalid_endpoint_syntax"); + assertValidEndpointScalars(value); + const match = ENDPOINT_RE.exec(value); + if (!match) fail("invalid_endpoint_syntax"); + let [, scheme, authority, rawPath] = match; + scheme = scheme.toLowerCase(); + if (scheme !== "http" && scheme !== "https") fail("unsupported_endpoint_scheme"); + if (!authority || authority.includes("@")) fail("invalid_endpoint_authority"); + + const bracketed = authority.startsWith("["); + let rawHost; + let rawPort = null; + if (bracketed) { + const close = authority.indexOf("]"); + if (close < 0) fail("invalid_endpoint_authority"); + rawHost = authority.slice(1, close); + const remainder = authority.slice(close + 1); + if (remainder) { + if (!remainder.startsWith(":")) fail("invalid_endpoint_authority"); + rawPort = remainder.slice(1); + } + } else { + if (authority.includes("[") || authority.includes("]") || (authority.match(/:/g) || []).length > 1) { + fail("invalid_endpoint_authority"); + } + if (authority.includes(":")) { + const split = authority.lastIndexOf(":"); + rawHost = authority.slice(0, split); + rawPort = authority.slice(split + 1); + } else { + rawHost = authority; + } + } + + const host = canonicalizeHost(rawHost, bracketed); + let canonicalPort = ""; + if (rawPort !== null) { + if (!/^[1-9][0-9]*$/.test(rawPort)) fail("invalid_endpoint_port"); + const port = Number(rawPort); + if (port > 65535) fail("invalid_endpoint_port"); + if (!((scheme === "https" && port === 443) || (scheme === "http" && port === 80))) { + canonicalPort = `:${port}`; + } + } + + let path = normalizePath(rawPath); + if (!path) path = "/"; + else if (path !== "/") path = path.replace(/\/+$/u, "") || "/"; + + if (scheme === "http") { + const loopback = + (host.kind === "registered" && host.value === "localhost") || + (host.kind === "ipv4" && host.value[0] === 127) || + (host.kind === "ipv6" && ipv6IsLoopback(host.value)); + if (!loopback) fail("insecure_endpoint"); + } + return `${scheme}://${host.canonical}${canonicalPort}${path}`; +} + +function environmentName(resource) { + const segments = validateResource(resource); + const encode = segment => + [...segment].map(character => (character === "-" ? "_H" : character.toUpperCase())).join(""); + return `DETERMA_${segments.map(encode).join("__")}_CONNECTION`; +} + +function routeKeys(resource) { + const product = resource.split("/", 1)[0]; + return product === resource ? [resource] : [resource, product]; +} + +function firstRoute(routes, resource) { + for (const key of routeKeys(resource)) { + if (Object.prototype.hasOwnProperty.call(routes, key)) return routes[key]; + } + return null; +} + +function resolveConnection(configuration, request) { + const resource = request.resource; + validateResource(resource); + const connections = configuration.connections; + + if (Object.prototype.hasOwnProperty.call(request, "explicit_connection")) { + const explicit = request.explicit_connection; + if (typeof explicit !== "string" || !Object.prototype.hasOwnProperty.call(connections, explicit)) { + fail("invalid_connection"); + } + return explicit; + } + + const environment = Object.prototype.hasOwnProperty.call(request, "environment") ? request.environment : {}; + if ( + !isObject(environment) || + Object.entries(environment).some(([key, value]) => typeof key !== "string" || typeof value !== "string") + ) { + fail("invalid_environment"); + } + const environmentKeys = [environmentName(resource)]; + const productKey = environmentName(resource.split("/", 1)[0]); + if (!environmentKeys.includes(productKey)) environmentKeys.push(productKey); + environmentKeys.push("DETERMA_CONNECTION"); + for (const key of environmentKeys) { + if (Object.prototype.hasOwnProperty.call(environment, key)) { + const connection = environment[key]; + if (!connection || !Object.prototype.hasOwnProperty.call(connections, connection)) fail("invalid_connection"); + return connection; + } + } + + if (Object.prototype.hasOwnProperty.call(request, "selected_context")) { + const selected = request.selected_context; + if (typeof selected !== "string" || !Object.prototype.hasOwnProperty.call(configuration.contexts, selected)) { + fail("invalid_context"); + } + const connection = firstRoute(configuration.contexts[selected].routes, resource); + if (connection !== null) return connection; + } + + const defaults = configuration.defaults || { routes: {} }; + const defaultConnection = firstRoute(defaults.routes, resource); + if (defaultConnection !== null) return defaultConnection; + + const defaultContext = configuration.default_context; + if (defaultContext !== undefined) { + const connection = firstRoute(configuration.contexts[defaultContext].routes, resource); + if (connection !== null) return connection; + } + fail("unresolved_connection"); +} + +module.exports = { + FamilyConnectionError, + RESERVED_FAMILY_COMMANDS, + canonicalizeEndpoint, + environmentName, + parseConfigurationSource, + resolveConnection, + validateConfiguration, + validateResource, +}; diff --git a/node/package-lock.json b/node/package-lock.json new file mode 100644 index 0000000..4186899 --- /dev/null +++ b/node/package-lock.json @@ -0,0 +1,43 @@ +{ + "name": "determa", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "determa", + "version": "0.2.0", + "license": "MIT", + "dependencies": { + "tr46": "5.0.0" + }, + "bin": { + "determa": "bin/determa.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/node/package.json b/node/package.json index e5caac2..da8a382 100644 --- a/node/package.json +++ b/node/package.json @@ -2,15 +2,39 @@ "name": "determa", "version": "0.2.0", "description": "Umbrella launcher for the Determa family — git-style dispatch to determa- commands", - "keywords": ["determa", "cli", "launcher", "statechart", "dispatcher"], + "keywords": [ + "determa", + "cli", + "launcher", + "statechart", + "dispatcher" + ], "license": "MIT", "author": "fruwehq", - "repository": { "type": "git", "url": "https://github.com/fruwehq/determa.git" }, + "repository": { + "type": "git", + "url": "https://github.com/fruwehq/determa.git" + }, "homepage": "https://github.com/fruwehq/determa", - "bin": { "determa": "bin/determa.js" }, - "files": ["bin/", "README.md"], - "engines": { "node": ">=16" }, + "bin": { + "determa": "bin/determa.js" + }, + "exports": { + "./family-connection-context-v1": "./lib/family-connection-context-v1.js", + "./package.json": "./package.json" + }, + "files": [ + "bin/", + "lib/", + "README.md" + ], + "engines": { + "node": ">=18" + }, "scripts": { - "test": "node test/dispatch.test.js" + "test": "node test/dispatch.test.js && node test/family-connection-context-v1.test.js" + }, + "dependencies": { + "tr46": "5.0.0" } } diff --git a/node/test/dispatch.test.js b/node/test/dispatch.test.js index 530d35a..5a11fe1 100644 --- a/node/test/dispatch.test.js +++ b/node/test/dispatch.test.js @@ -28,6 +28,20 @@ r = run(["definitely-not-real"]); assert.strictEqual(r.status, 127); assert.ok(r.stderr.includes("not found on PATH")); +for (const command of ["auth", "config", "context"]) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "determa-reserved-")); + const stub = path.join(dir, `determa-${command}`); + fs.writeFileSync(stub, '#!/bin/sh\necho "must-not-run"\nexit 0\n'); + fs.chmodSync(stub, 0o755); + r = run([command], { PATH: dir + path.delimiter + process.env.PATH }); + assert.strictEqual(r.status, 2); + assert.strictEqual(r.stdout, ""); + assert.strictEqual( + r.stderr, + `determa: family command '${command}' is reserved but not implemented yet.\n` + ); +} + if (process.platform !== "win32") { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "determa-test-")); const stub = path.join(dir, "determa-echo"); diff --git a/node/test/family-connection-context-v1.test.js b/node/test/family-connection-context-v1.test.js new file mode 100644 index 0000000..6a73c7c --- /dev/null +++ b/node/test/family-connection-context-v1.test.js @@ -0,0 +1,59 @@ +"use strict"; + +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const family = require("determa/family-connection-context-v1"); + +const ROOT = path.resolve(__dirname, "..", ".."); +const VECTOR_ROOT = path.join(ROOT, "conformance", "family-connection-v1"); + +function loadFixture(name) { + return JSON.parse(fs.readFileSync(path.join(VECTOR_ROOT, name), "utf8")); +} + +function assertCase(case_, operation) { + if (Object.prototype.hasOwnProperty.call(case_.expect, "error")) { + assert.throws( + operation, + error => error instanceof family.FamilyConnectionError && error.code === case_.expect.error, + case_.id + ); + } else { + assert.deepStrictEqual(operation(), case_.expect.value, case_.id); + } +} + +for (const case_ of loadFixture("configuration.json").cases) { + assertCase(case_, () => family.parseConfigurationSource(case_.source)); +} + +for (const case_ of loadFixture("endpoints.json").cases) { + assertCase(case_, () => family.canonicalizeEndpoint(case_.input)); +} + +const environmentFixture = loadFixture("environment.json"); +const environmentResults = new Map(); +for (const case_ of environmentFixture.cases) { + assertCase(case_, () => family.environmentName(case_.resource)); + if (Object.prototype.hasOwnProperty.call(case_.expect, "value")) { + environmentResults.set(case_.id, case_.expect.value); + } +} +for (const distinctSet of environmentFixture.distinct_sets || []) { + const values = distinctSet.map(caseId => environmentResults.get(caseId)); + assert.strictEqual(new Set(values).size, values.length, distinctSet.join(", ")); +} + +const routingFixture = loadFixture("routing.json"); +const configurations = {}; +for (const [name, source] of Object.entries(routingFixture.configurations)) { + configurations[name] = family.parseConfigurationSource(source); +} +for (const case_ of routingFixture.cases) { + const configuration = configurations[case_.configuration]; + assertCase(case_, () => family.resolveConnection(configuration, case_.request)); +} + +console.log("family connection/context v1 node vectors: 152 passed"); diff --git a/python/README.md b/python/README.md index 767b2a1..52d011a 100644 --- a/python/README.md +++ b/python/README.md @@ -20,6 +20,8 @@ The launcher is language-agnostic: it dispatches to whichever `determa-state` is `PATH`, be it the Python or the Rust build. It ships no `determa/__init__.py`, so it coexists cleanly with `determa.state` as a PEP 420 namespace. +The package also exposes the Family Connection/Context v1 resolver APIs. + ## License MIT diff --git a/python/pyproject.toml b/python/pyproject.toml index 7b4db07..c0761ff 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Topic :: Software Development", ] +dependencies = ["idna==3.10"] [project.optional-dependencies] # Convenience installs: `pip install "determa[state]"` gets the launcher + the @@ -39,7 +40,10 @@ determa = "determa._cli:main" # with determa-state's `determa.state` package. Ship only the launcher module. [tool.hatch.build.targets.wheel] sources = ["src"] -only-include = ["src/determa/_cli.py"] +only-include = [ + "src/determa/_cli.py", + "src/determa/family_connection_context_v1.py", +] [tool.ruff] line-length = 100 diff --git a/python/src/determa/_cli.py b/python/src/determa/_cli.py index 76a266e..931dce1 100644 --- a/python/src/determa/_cli.py +++ b/python/src/determa/_cli.py @@ -18,6 +18,7 @@ import sys PREFIX = "determa-" +RESERVED_FAMILY_COMMANDS = frozenset({"auth", "config", "context"}) def _version() -> str: @@ -147,6 +148,11 @@ def main(argv: list[str] | None = None) -> int: for product, impls in _discover().items(): print(_format_product(product, impls)) return 0 + if args[0] in RESERVED_FAMILY_COMMANDS: + sys.stderr.write( + f"determa: family command '{args[0]}' is reserved but not implemented yet.\n" + ) + return 2 sub, rest = args[0], args[1:] exe = _exe_for(sub) diff --git a/python/src/determa/family_connection_context_v1.py b/python/src/determa/family_connection_context_v1.py new file mode 100644 index 0000000..733fd7c --- /dev/null +++ b/python/src/determa/family_connection_context_v1.py @@ -0,0 +1,565 @@ +"""Family Connection/Context v1 production resolver APIs.""" + +from __future__ import annotations + +import bisect +import ipaddress +import json +import re +import unicodedata +from dataclasses import dataclass +from typing import Any + +import idna +from idna import idnadata, uts46data +from idna.core import valid_contextj + +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" + + +class FamilyConnectionError(ValueError): + """Raised when a v1 value violates the public resolver contract.""" + + 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 _error(code: str) -> FamilyConnectionError: + return FamilyConnectionError(code) + + +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 _error("duplicate_key") + result[key] = value + return result + + +def _reject_nonfinite_constant(_value: str) -> Any: + raise _error("invalid_source") + + +def parse_configuration_source(source: Any) -> dict[str, Any]: + """Parse and validate a JSON source string into the closed logical model.""" + + if not isinstance(source, str): + raise _error("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 FamilyConnectionError: + raise + except (json.JSONDecodeError, UnicodeError) as exc: + raise _error("invalid_source") from exc + 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 _error("invalid_type") + if not required.issubset(value): + raise _error("missing_field") + if set(value) - required - optional: + raise _error("unknown_field") + return value + + +def _require_name(value: Any) -> str: + if not isinstance(value, str): + raise _error("invalid_type") + if not NAME_RE.fullmatch(value): + raise _error("invalid_name") + return value + + +def _require_nonempty_string(value: Any) -> str: + if not isinstance(value, str): + raise _error("invalid_type") + if not value: + raise _error("invalid_name") + return value + + +def _validate_routes(value: Any, connection_names: set[str]) -> dict[str, str]: + if not isinstance(value, dict): + raise _error("invalid_type") + result: dict[str, str] = {} + for resource, connection in value.items(): + validate_resource(resource) + if not isinstance(connection, str): + raise _error("invalid_type") + if connection not in connection_names: + raise _error("invalid_reference") + result[resource] = connection + return result + + +def validate_configuration(value: Any) -> dict[str, Any]: + """Validate a decoded JSON-like configuration value.""" + + root = _require_closed_object( + value, + {"version", "connections", "contexts"}, + {"default_context", "defaults"}, + ) + version = root["version"] + if not isinstance(version, IntegerToken) or version.source != "1": + raise _error("invalid_version") + + raw_connections = root["connections"] + if not isinstance(raw_connections, dict): + raise _error("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 _error("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 _error("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 _error("invalid_type") + segments = value.split("/") + if not segments or any(not NAME_RE.fullmatch(segment) for segment in segments): + raise _error("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: + raise idna.IDNAError("label must be non-empty") + 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 as exc: + raise idna.IDNAError("invalid Punycode label") from exc + 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 _error("invalid_endpoint_host") + if bracketed: + try: + address = ipaddress.IPv6Address(raw_host) + except ipaddress.AddressValueError as exc: + raise _error("invalid_endpoint_host") from exc + 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 _error("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 _error("invalid_endpoint_host") + + ascii_host = _domain_to_ascii(raw_host) + if not ascii_host or ascii_host.endswith("."): + raise _error("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 _error("invalid_endpoint_host") + canonical = ".".join(labels) + if len(canonical) > 253: + raise _error("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 _error("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 _error("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 _error("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 _error("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: + """Canonicalize one endpoint string according to the v1 endpoint profile.""" + + if not isinstance(value, str): + raise _error("invalid_endpoint_type") + if not value: + raise _error("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 _error("invalid_endpoint_characters") + match = ENDPOINT_RE.fullmatch(value) + if not match: + raise _error("invalid_endpoint_syntax") + scheme, authority, raw_path = match.groups() + scheme = scheme.lower() + if scheme not in {"http", "https"}: + raise _error("unsupported_endpoint_scheme") + if not authority or "@" in authority: + raise _error("invalid_endpoint_authority") + + bracketed = authority.startswith("[") + raw_port: Any = None + if bracketed: + close = authority.find("]") + if close < 0: + raise _error("invalid_endpoint_authority") + raw_host = authority[1:close] + remainder = authority[close + 1 :] + if remainder: + if not remainder.startswith(":"): + raise _error("invalid_endpoint_authority") + raw_port = remainder[1:] + else: + if "[" in authority or "]" in authority or authority.count(":") > 1: + raise _error("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 _error("invalid_endpoint_port") + port = int(raw_port) + if port > 65535: + raise _error("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 _error("insecure_endpoint") + return f"{scheme}://{canonical_host}{canonical_port}{path}" + + +def environment_name(resource: Any) -> str: + """Return the injective v1 environment override variable for a resource.""" + + 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: + """Resolve one request to a named connection using exact v1 precedence.""" + + 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 _error("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 _error("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 _error("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 _error("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 _error("unresolved_connection") diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index 812ecb2..e07c941 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -32,6 +32,22 @@ def test_unknown_product_exits_127(capsys): assert "not found on PATH" in capsys.readouterr().err +@pytest.mark.parametrize("command", ["auth", "config", "context"]) +def test_reserved_family_command_exits_2(command, tmp_path, monkeypatch, capsys): + exe = tmp_path / f"determa-{command}" + exe.write_text('#!/bin/sh\necho "must-not-run"\nexit 0\n') + exe.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + + assert cli.main([command]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert ( + captured.err + == f"determa: family command '{command}' is reserved but not implemented yet.\n" + ) + + @pytest.mark.skipif(sys.platform == "win32", reason="uses a POSIX shell stub") def test_dispatches_to_product_on_path(tmp_path, monkeypatch, capfd): exe = tmp_path / "determa-echo" diff --git a/python/tests/test_family_connection_context_v1.py b/python/tests/test_family_connection_context_v1.py new file mode 100644 index 0000000..247a6eb --- /dev/null +++ b/python/tests/test_family_connection_context_v1.py @@ -0,0 +1,83 @@ +"""Shared Family Connection/Context v1 fixture tests for the Python implementation.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Callable + +import pytest + +from determa import family_connection_context_v1 as family + +ROOT = Path(__file__).resolve().parents[2] +VECTOR_ROOT = ROOT / "conformance" / "family-connection-v1" + + +def load_fixture(name: str) -> dict[str, Any]: + with (VECTOR_ROOT / name).open(encoding="utf-8") as fixture_file: + return json.load(fixture_file) + + +def assert_case(case: dict[str, Any], operation: Callable[[], Any]) -> None: + expected = case["expect"] + if "error" in expected: + with pytest.raises(family.FamilyConnectionError) as exc_info: + operation() + assert exc_info.value.code == expected["error"] + else: + assert operation() == expected["value"] + + +def test_unicode_data_version() -> None: + family.validate_unicode_data_version() + + +@pytest.mark.parametrize( + "case", load_fixture("configuration.json")["cases"], ids=lambda case: case["id"] +) +def test_configuration_vectors(case: dict[str, Any]) -> None: + assert_case(case, lambda: family.parse_configuration_source(case["source"])) + + +@pytest.mark.parametrize( + "case", load_fixture("endpoints.json")["cases"], ids=lambda case: case["id"] +) +def test_endpoint_vectors(case: dict[str, Any]) -> None: + assert_case(case, lambda: family.canonicalize_endpoint(case["input"])) + + +@pytest.mark.parametrize( + "case", load_fixture("environment.json")["cases"], ids=lambda case: case["id"] +) +def test_environment_vectors(case: dict[str, Any]) -> None: + assert_case(case, lambda: family.environment_name(case["resource"])) + + +def test_environment_distinct_sets() -> None: + fixture = load_fixture("environment.json") + results = { + case["id"]: family.environment_name(case["resource"]) + for case in fixture["cases"] + if "value" in case["expect"] + } + for distinct_set in fixture.get("distinct_sets", []): + values = [results[case_id] for case_id in distinct_set] + assert len(values) == len(set(values)) + + +@pytest.fixture(scope="module") +def routing_configurations() -> dict[str, dict[str, Any]]: + fixture = load_fixture("routing.json") + return { + name: family.parse_configuration_source(source) + for name, source in fixture["configurations"].items() + } + + +@pytest.mark.parametrize("case", load_fixture("routing.json")["cases"], ids=lambda case: case["id"]) +def test_routing_vectors( + case: dict[str, Any], routing_configurations: dict[str, dict[str, Any]] +) -> None: + configuration = routing_configurations[case["configuration"]] + assert_case(case, lambda: family.resolve_connection(configuration, case["request"])) diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..039ba31 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,508 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "determa" +version = "0.2.0" +dependencies = [ + "icu_normalizer", + "icu_normalizer_data", + "icu_properties", + "icu_properties_data", + "idna", + "idna_adapter", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 5f983e4..03699e3 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,6 +2,7 @@ name = "determa" version = "0.2.0" edition = "2021" +rust-version = "1.67" authors = ["fruwehq"] license = "MIT" description = "Umbrella launcher for the Determa family — git-style dispatch to determa- commands" @@ -14,5 +15,15 @@ categories = ["command-line-utilities"] name = "determa" path = "src/main.rs" +[dependencies] +icu_normalizer = "=1.5.0" +icu_normalizer_data = "=1.5.1" +icu_properties = "=1.5.1" +icu_properties_data = "=1.5.1" +idna = "=1.1.0" +idna_adapter = "=1.2.0" +serde = "1" +serde_json = "1" + [dev-dependencies] tempfile = "3" diff --git a/rust/README.md b/rust/README.md index 8031e8b..ce10959 100644 --- a/rust/README.md +++ b/rust/README.md @@ -13,7 +13,14 @@ $ determa --version ``` It is language-agnostic: it dispatches to whichever `determa-state` is on `PATH`, be it -the Rust or the Python build. A single static binary with no dependencies. +the Rust or the Python build. + +The crate also exposes the Family Connection/Context v1 resolver APIs. The +package MSRV is Rust 1.67 because the pinned UTS #46 backend used for the +resolver requires it. The behavior-relevant ICU data crates are exact +dependencies, and `Cargo.lock` is checked in for this binary crate so the +verified Unicode 15.1 endpoint boundary is reproducible in CI and locked +installs. ## License diff --git a/rust/src/family_connection_context_v1.rs b/rust/src/family_connection_context_v1.rs new file mode 100644 index 0000000..1f1595c --- /dev/null +++ b/rust/src/family_connection_context_v1.rs @@ -0,0 +1,809 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; + +use idna::uts46::{AsciiDenyList, DnsLength, Hyphens, Uts46}; +use serde::de::{self, MapAccess, SeqAccess, Visitor}; +use serde::Deserialize; +use serde_json::{json, Map, Value}; + +pub const RESERVED_FAMILY_COMMANDS: &[&str] = &["auth", "config", "context"]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FamilyConnectionError { + pub code: &'static str, +} + +impl FamilyConnectionError { + fn new(code: &'static str) -> Self { + Self { code } + } +} + +impl fmt::Display for FamilyConnectionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.code) + } +} + +impl std::error::Error for FamilyConnectionError {} + +type Result = std::result::Result; + +#[derive(Debug, Clone)] +enum JsonValue { + Null, + Bool, + Integer(String), + Number, + String(String), + Array, + Object(BTreeMap), +} + +struct JsonValueVisitor; + +impl<'de> Visitor<'de> for JsonValueVisitor { + type Value = JsonValue; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a JSON value") + } + + fn visit_bool(self, _value: bool) -> std::result::Result { + Ok(JsonValue::Bool) + } + + fn visit_i64(self, value: i64) -> std::result::Result { + Ok(JsonValue::Integer(value.to_string())) + } + + fn visit_u64(self, value: u64) -> std::result::Result { + Ok(JsonValue::Integer(value.to_string())) + } + + fn visit_f64(self, _value: f64) -> std::result::Result { + Ok(JsonValue::Number) + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: de::Error, + { + Ok(JsonValue::String(value.to_string())) + } + + fn visit_string(self, value: String) -> std::result::Result { + Ok(JsonValue::String(value)) + } + + fn visit_none(self) -> std::result::Result { + Ok(JsonValue::Null) + } + + fn visit_unit(self) -> std::result::Result { + Ok(JsonValue::Null) + } + + fn visit_seq(self, mut sequence: A) -> std::result::Result + where + A: SeqAccess<'de>, + { + while sequence.next_element::()?.is_some() {} + Ok(JsonValue::Array) + } + + fn visit_map(self, mut map: A) -> std::result::Result + where + A: MapAccess<'de>, + { + let mut result = BTreeMap::new(); + while let Some(key) = map.next_key::()? { + if result.contains_key(&key) { + return Err(de::Error::custom("duplicate_key")); + } + let value = map.next_value::()?; + result.insert(key, value); + } + Ok(JsonValue::Object(result)) + } +} + +impl<'de> Deserialize<'de> for JsonValue { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + deserializer.deserialize_any(JsonValueVisitor) + } +} + +pub fn parse_configuration_source(source: &str) -> Result { + let parsed = serde_json::from_str::(source).map_err(|error| { + if error.to_string().contains("duplicate_key") { + FamilyConnectionError::new("duplicate_key") + } else { + FamilyConnectionError::new("invalid_source") + } + })?; + validate_configuration(&parsed) +} + +fn object<'a>( + value: &'a JsonValue, + required: &[&str], + optional: &[&str], +) -> Result<&'a BTreeMap> { + let JsonValue::Object(map) = value else { + return Err(FamilyConnectionError::new("invalid_type")); + }; + for field in required { + if !map.contains_key(*field) { + return Err(FamilyConnectionError::new("missing_field")); + } + } + for field in map.keys() { + if !required.contains(&field.as_str()) && !optional.contains(&field.as_str()) { + return Err(FamilyConnectionError::new("unknown_field")); + } + } + Ok(map) +} + +fn name_from_json(value: &JsonValue) -> Result { + let JsonValue::String(value) = value else { + return Err(FamilyConnectionError::new("invalid_type")); + }; + require_name(value) +} + +fn require_name(value: &str) -> Result { + if valid_name(value) { + Ok(value.to_string()) + } else { + Err(FamilyConnectionError::new("invalid_name")) + } +} + +fn require_nonempty_string(value: &JsonValue) -> Result { + let JsonValue::String(value) = value else { + return Err(FamilyConnectionError::new("invalid_type")); + }; + if value.is_empty() { + Err(FamilyConnectionError::new("invalid_name")) + } else { + Ok(value.to_string()) + } +} + +fn valid_name(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_lowercase() { + return false; + } + chars.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) +} + +fn validate_routes( + value: &JsonValue, + connection_names: &BTreeSet, +) -> Result> { + let JsonValue::Object(routes) = value else { + return Err(FamilyConnectionError::new("invalid_type")); + }; + let mut result = Map::new(); + for (resource, connection) in routes { + validate_resource(resource)?; + let JsonValue::String(connection) = connection else { + return Err(FamilyConnectionError::new("invalid_type")); + }; + if !connection_names.contains(connection) { + return Err(FamilyConnectionError::new("invalid_reference")); + } + result.insert(resource.clone(), Value::String(connection.clone())); + } + Ok(result) +} + +fn validate_configuration(value: &JsonValue) -> Result { + let root = object( + value, + &["version", "connections", "contexts"], + &["default_context", "defaults"], + )?; + match root.get("version") { + Some(JsonValue::Integer(source)) if source == "1" => {} + _ => return Err(FamilyConnectionError::new("invalid_version")), + } + + let JsonValue::Object(raw_connections) = root.get("connections").expect("required") else { + return Err(FamilyConnectionError::new("invalid_type")); + }; + let mut connections = Map::new(); + for (raw_name, raw_connection) in raw_connections { + let name = require_name(raw_name)?; + let connection = object(raw_connection, &["endpoint"], &["credential_ref"])?; + let endpoint = canonicalize_endpoint_json(connection.get("endpoint").expect("required"))?; + let mut normalized = Map::new(); + normalized.insert("endpoint".to_string(), Value::String(endpoint)); + if let Some(credential) = connection.get("credential_ref") { + let credential = object(credential, &["provider", "name"], &[])?; + normalized.insert( + "credential_ref".to_string(), + json!({ + "provider": name_from_json(credential.get("provider").expect("required"))?, + "name": require_nonempty_string(credential.get("name").expect("required"))?, + }), + ); + } + connections.insert(name, Value::Object(normalized)); + } + + let connection_names: BTreeSet = connections.keys().cloned().collect(); + let JsonValue::Object(raw_contexts) = root.get("contexts").expect("required") else { + return Err(FamilyConnectionError::new("invalid_type")); + }; + let mut contexts = Map::new(); + for (raw_name, raw_context) in raw_contexts { + let name = require_name(raw_name)?; + let context = object(raw_context, &["routes"], &[])?; + contexts.insert( + name, + json!({ "routes": validate_routes(context.get("routes").expect("required"), &connection_names)? }), + ); + } + + let mut normalized = Map::new(); + normalized.insert("version".to_string(), Value::Number(1.into())); + normalized.insert("connections".to_string(), Value::Object(connections)); + normalized.insert("contexts".to_string(), Value::Object(contexts.clone())); + if let Some(default_context) = root.get("default_context") { + let default_context = name_from_json(default_context)?; + if !contexts.contains_key(&default_context) { + return Err(FamilyConnectionError::new("invalid_reference")); + } + normalized.insert( + "default_context".to_string(), + Value::String(default_context), + ); + } + if let Some(defaults) = root.get("defaults") { + let defaults = object(defaults, &["routes"], &[])?; + normalized.insert( + "defaults".to_string(), + json!({ "routes": validate_routes(defaults.get("routes").expect("required"), &connection_names)? }), + ); + } + Ok(Value::Object(normalized)) +} + +pub fn validate_resource(value: &str) -> Result> { + let segments: Vec<&str> = value.split('/').collect(); + if segments.is_empty() || segments.iter().any(|segment| !valid_name(segment)) { + return Err(FamilyConnectionError::new("invalid_name")); + } + Ok(segments) +} + +pub fn canonicalize_endpoint_value(value: &Value) -> Result { + let Some(value) = value.as_str() else { + return Err(FamilyConnectionError::new("invalid_endpoint_type")); + }; + canonicalize_endpoint(value) +} + +fn canonicalize_endpoint_json(value: &JsonValue) -> Result { + let JsonValue::String(value) = value else { + return Err(FamilyConnectionError::new("invalid_endpoint_type")); + }; + canonicalize_endpoint(value) +} + +pub fn canonicalize_endpoint(value: &str) -> Result { + if value.is_empty() { + return Err(FamilyConnectionError::new("invalid_endpoint_syntax")); + } + for character in value.chars() { + if character == '\\' || character == ' ' || character <= '\u{1f}' || character == '\u{7f}' { + return Err(FamilyConnectionError::new("invalid_endpoint_characters")); + } + } + let scheme_end = value + .find("://") + .ok_or_else(|| FamilyConnectionError::new("invalid_endpoint_syntax"))?; + let mut scheme = value[..scheme_end].to_ascii_lowercase(); + if !valid_scheme(&scheme) { + return Err(FamilyConnectionError::new("invalid_endpoint_syntax")); + } + if scheme != "http" && scheme != "https" { + return Err(FamilyConnectionError::new("unsupported_endpoint_scheme")); + } + let after_scheme = &value[scheme_end + 3..]; + if after_scheme.contains('?') || after_scheme.contains('#') { + return Err(FamilyConnectionError::new("invalid_endpoint_syntax")); + } + let slash = after_scheme.find('/'); + let (authority, raw_path) = match slash { + Some(index) => (&after_scheme[..index], &after_scheme[index..]), + None => (after_scheme, ""), + }; + if authority.is_empty() || authority.contains('@') { + return Err(FamilyConnectionError::new("invalid_endpoint_authority")); + } + + let (raw_host, raw_port, bracketed) = split_authority(authority)?; + let (canonical_host, host_kind) = canonicalize_host(raw_host, bracketed)?; + let canonical_port = match raw_port { + Some(raw_port) => { + if raw_port.is_empty() + || raw_port.starts_with('0') + || !raw_port.chars().all(|character| character.is_ascii_digit()) + { + return Err(FamilyConnectionError::new("invalid_endpoint_port")); + } + let port: u32 = raw_port + .parse() + .map_err(|_| FamilyConnectionError::new("invalid_endpoint_port"))?; + if port == 0 || port > 65535 { + return Err(FamilyConnectionError::new("invalid_endpoint_port")); + } + if (scheme == "https" && port == 443) || (scheme == "http" && port == 80) { + String::new() + } else { + format!(":{port}") + } + } + None => String::new(), + }; + + let mut path = normalize_path(raw_path)?; + if path.is_empty() { + path = "/".to_string(); + } else if path != "/" { + while path.ends_with('/') { + path.pop(); + } + if path.is_empty() { + path = "/".to_string(); + } + } + + if scheme == "http" && !host_kind.is_loopback() { + return Err(FamilyConnectionError::new("insecure_endpoint")); + } + scheme.push_str("://"); + Ok(format!("{scheme}{canonical_host}{canonical_port}{path}")) +} + +fn valid_scheme(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_alphabetic() + && chars.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '.' | '-') + }) +} + +fn split_authority(authority: &str) -> Result<(&str, Option<&str>, bool)> { + if authority.starts_with('[') { + let close = authority + .find(']') + .ok_or_else(|| FamilyConnectionError::new("invalid_endpoint_authority"))?; + let raw_host = &authority[1..close]; + let remainder = &authority[close + 1..]; + if remainder.is_empty() { + return Ok((raw_host, None, true)); + } + if let Some(raw_port) = remainder.strip_prefix(':') { + return Ok((raw_host, Some(raw_port), true)); + } + return Err(FamilyConnectionError::new("invalid_endpoint_authority")); + } + if authority.contains('[') || authority.contains(']') || authority.matches(':').count() > 1 { + return Err(FamilyConnectionError::new("invalid_endpoint_authority")); + } + if let Some(split) = authority.rfind(':') { + Ok((&authority[..split], Some(&authority[split + 1..]), false)) + } else { + Ok((authority, None, false)) + } +} + +enum HostKind { + Registered(String), + Ipv4(Ipv4Addr), + Ipv6(Ipv6Addr), +} + +impl HostKind { + fn is_loopback(&self) -> bool { + match self { + HostKind::Registered(host) => host == "localhost", + HostKind::Ipv4(address) => address.is_loopback(), + HostKind::Ipv6(address) => *address == Ipv6Addr::LOCALHOST, + } + } +} + +fn canonicalize_host(raw_host: &str, bracketed: bool) -> Result<(String, HostKind)> { + if raw_host.is_empty() || raw_host.contains('%') { + return Err(FamilyConnectionError::new("invalid_endpoint_host")); + } + if bracketed { + let address = Ipv6Addr::from_str(raw_host) + .map_err(|_| FamilyConnectionError::new("invalid_endpoint_host"))?; + return Ok(( + format!("[{}]", canonicalize_ipv6(address)), + HostKind::Ipv6(address), + )); + } + + if is_strict_ipv4(raw_host) { + let octets = parse_ipv4_octets(raw_host)?; + let address = Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]); + return Ok((address.to_string(), HostKind::Ipv4(address))); + } + if is_ipv4_like(raw_host) { + return Err(FamilyConnectionError::new("invalid_endpoint_host")); + } + + let ascii_host = domain_to_ascii(raw_host)?; + if ascii_host.is_empty() || ascii_host.ends_with('.') { + return Err(FamilyConnectionError::new("invalid_endpoint_host")); + } + let labels: Vec = ascii_host + .split('.') + .map(|label| label.to_ascii_lowercase()) + .collect(); + if labels.iter().any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + }) { + return Err(FamilyConnectionError::new("invalid_endpoint_host")); + } + let canonical = labels.join("."); + if canonical.len() > 253 { + return Err(FamilyConnectionError::new("invalid_endpoint_host")); + } + Ok((canonical.clone(), HostKind::Registered(canonical))) +} + +fn domain_to_ascii(host: &str) -> Result { + let ascii = Uts46::new() + .to_ascii( + host.as_bytes(), + AsciiDenyList::STD3, + Hyphens::Check, + DnsLength::Verify, + ) + .map_err(|_| FamilyConnectionError::new("invalid_endpoint_host"))?; + Ok(ascii.into_owned()) +} + +fn is_strict_ipv4(raw_host: &str) -> bool { + let parts: Vec<&str> = raw_host.split('.').collect(); + parts.len() == 4 + && parts.iter().all(|part| { + let mut chars = part.chars(); + let Some(first) = chars.next() else { + return false; + }; + *part == "0" + || (('1'..='9').contains(&first) + && chars.all(|character| character.is_ascii_digit())) + }) +} + +fn parse_ipv4_octets(raw_host: &str) -> Result<[u8; 4]> { + let mut octets = [0_u8; 4]; + for (index, part) in raw_host.split('.').enumerate() { + octets[index] = part + .parse::() + .map_err(|_| FamilyConnectionError::new("invalid_endpoint_host"))?; + } + Ok(octets) +} + +fn is_ipv4_like(raw_host: &str) -> bool { + let parts: Vec<&str> = raw_host.split('.').collect(); + !parts.is_empty() + && parts.iter().all(|part| { + if part.is_empty() { + return false; + } + let lower = part.to_ascii_lowercase(); + if let Some(hex) = lower.strip_prefix("0x") { + !hex.is_empty() && hex.chars().all(|character| character.is_ascii_hexdigit()) + } else { + part.chars().all(|character| character.is_ascii_digit()) + } + }) +} + +fn canonicalize_ipv6(address: Ipv6Addr) -> String { + let groups = address.segments(); + let mut best_start = None; + let mut best_length = 0_usize; + let mut index = 0_usize; + while index < groups.len() { + if groups[index] != 0 { + index += 1; + continue; + } + let start = index; + while index < groups.len() && groups[index] == 0 { + index += 1; + } + let length = index - start; + if length >= 2 && length > best_length { + best_start = Some(start); + best_length = length; + } + } + let rendered: Vec = groups.iter().map(|group| format!("{group:x}")).collect(); + let Some(start) = best_start else { + return rendered.join(":"); + }; + let left = rendered[..start].join(":"); + let right = rendered[start + best_length..].join(":"); + match (left.is_empty(), right.is_empty()) { + (false, false) => format!("{left}::{right}"), + (false, true) => format!("{left}::"), + (true, false) => format!("::{right}"), + (true, true) => "::".to_string(), + } +} + +fn normalize_path(raw_path: &str) -> Result { + let mut output = String::new(); + let mut index = 0_usize; + let bytes = raw_path.as_bytes(); + while index < bytes.len() { + let byte = bytes[index]; + if byte > 0x7f { + return Err(FamilyConnectionError::new("invalid_endpoint_path")); + } + if byte == b'%' { + if index + 2 >= bytes.len() || !is_hex(bytes[index + 1]) || !is_hex(bytes[index + 2]) { + return Err(FamilyConnectionError::new("invalid_endpoint_path")); + } + let octet = hex_value(bytes[index + 1]) * 16 + hex_value(bytes[index + 2]); + if octet >= 0x80 || octet <= 0x1f || matches!(octet, 0x7f | b'/' | b'\\') { + return Err(FamilyConnectionError::new("invalid_endpoint_path")); + } + let decoded = char::from(octet); + if is_unreserved(decoded) { + output.push(decoded); + } else { + output.push_str(&format!("%{octet:02X}")); + } + index += 3; + continue; + } + let character = char::from(byte); + if character != '/' && !is_pchar(character) { + return Err(FamilyConnectionError::new("invalid_endpoint_path")); + } + output.push(character); + index += 1; + } + Ok(remove_dot_segments(&output)) +} + +fn is_hex(byte: u8) -> bool { + byte.is_ascii_hexdigit() +} + +fn hex_value(byte: u8) -> u8 { + match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + b'A'..=b'F' => byte - b'A' + 10, + _ => unreachable!(), + } +} + +fn is_unreserved(character: char) -> bool { + character.is_ascii_alphanumeric() || matches!(character, '-' | '.' | '_' | '~') +} + +fn is_pchar(character: char) -> bool { + is_unreserved(character) + || matches!( + character, + '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '=' | ':' | '@' + ) +} + +fn remove_last_segment(path: &str) -> String { + path.rfind('/') + .map_or_else(String::new, |slash| path[..slash].to_string()) +} + +fn remove_dot_segments(path: &str) -> String { + let mut source = path.to_string(); + let mut output = String::new(); + while !source.is_empty() { + if source.starts_with("../") { + source = source[3..].to_string(); + } else if source.starts_with("./") { + source = source[2..].to_string(); + } else if source.starts_with("/./") { + source = format!("/{}", &source[3..]); + } else if source == "/." { + source = "/".to_string(); + } else if source.starts_with("/../") { + source = format!("/{}", &source[4..]); + output = remove_last_segment(&output); + } else if source == "/.." { + source = "/".to_string(); + output = remove_last_segment(&output); + } else if source == "." || source == ".." { + source.clear(); + } else { + let start = usize::from(source.starts_with('/')); + if let Some(relative_slash) = source[start..].find('/') { + let slash = start + relative_slash; + output.push_str(&source[..slash]); + source = source[slash..].to_string(); + } else { + output.push_str(&source); + source.clear(); + } + } + } + output +} + +pub fn environment_name(resource: &str) -> Result { + let segments = validate_resource(resource)?; + let encoded: Vec = segments + .iter() + .map(|segment| { + segment + .chars() + .map(|character| { + if character == '-' { + "_H".to_string() + } else { + character.to_ascii_uppercase().to_string() + } + }) + .collect() + }) + .collect(); + Ok(format!("DETERMA_{}_CONNECTION", encoded.join("__"))) +} + +pub fn environment_name_value(resource: &Value) -> Result { + let Some(resource) = resource.as_str() else { + return Err(FamilyConnectionError::new("invalid_type")); + }; + environment_name(resource) +} + +fn route_keys(resource: &str) -> Vec<&str> { + let product = resource + .split_once('/') + .map_or(resource, |(product, _)| product); + if product == resource { + vec![resource] + } else { + vec![resource, product] + } +} + +fn first_route(routes: &Map, resource: &str) -> Option { + route_keys(resource) + .into_iter() + .find_map(|key| routes.get(key).and_then(Value::as_str).map(str::to_string)) +} + +pub fn resolve_connection(configuration: &Value, request: &Value) -> Result { + let request = request + .as_object() + .ok_or_else(|| FamilyConnectionError::new("invalid_type"))?; + let resource = request + .get("resource") + .and_then(Value::as_str) + .ok_or_else(|| FamilyConnectionError::new("invalid_type"))?; + validate_resource(resource)?; + let connections = configuration + .get("connections") + .and_then(Value::as_object) + .ok_or_else(|| FamilyConnectionError::new("invalid_type"))?; + + if let Some(explicit) = request.get("explicit_connection") { + let Some(explicit) = explicit.as_str() else { + return Err(FamilyConnectionError::new("invalid_connection")); + }; + if !connections.contains_key(explicit) { + return Err(FamilyConnectionError::new("invalid_connection")); + } + return Ok(explicit.to_string()); + } + + let empty_environment = Map::new(); + let environment = match request.get("environment") { + Some(Value::Object(environment)) => environment, + Some(_) => return Err(FamilyConnectionError::new("invalid_environment")), + None => &empty_environment, + }; + if environment.values().any(|value| !value.is_string()) { + return Err(FamilyConnectionError::new("invalid_environment")); + } + let mut environment_keys = vec![environment_name(resource)?]; + let product = resource + .split_once('/') + .map_or(resource, |(product, _)| product); + let product_key = environment_name(product)?; + if !environment_keys.contains(&product_key) { + environment_keys.push(product_key); + } + environment_keys.push("DETERMA_CONNECTION".to_string()); + for key in environment_keys { + if let Some(connection) = environment.get(&key) { + let connection = connection.as_str().expect("checked"); + if connection.is_empty() || !connections.contains_key(connection) { + return Err(FamilyConnectionError::new("invalid_connection")); + } + return Ok(connection.to_string()); + } + } + + let contexts = configuration + .get("contexts") + .and_then(Value::as_object) + .ok_or_else(|| FamilyConnectionError::new("invalid_type"))?; + if let Some(selected) = request.get("selected_context") { + let Some(selected) = selected.as_str() else { + return Err(FamilyConnectionError::new("invalid_context")); + }; + let Some(context) = contexts.get(selected).and_then(Value::as_object) else { + return Err(FamilyConnectionError::new("invalid_context")); + }; + if let Some(connection) = first_route( + context + .get("routes") + .and_then(Value::as_object) + .expect("normalized"), + resource, + ) { + return Ok(connection); + } + } + + if let Some(connection) = configuration + .get("defaults") + .and_then(|defaults| defaults.get("routes")) + .and_then(Value::as_object) + .and_then(|routes| first_route(routes, resource)) + { + return Ok(connection); + } + + if let Some(default_context) = configuration.get("default_context").and_then(Value::as_str) { + if let Some(connection) = contexts + .get(default_context) + .and_then(|context| context.get("routes")) + .and_then(Value::as_object) + .and_then(|routes| first_route(routes, resource)) + { + return Ok(connection); + } + } + Err(FamilyConnectionError::new("unresolved_connection")) +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs new file mode 100644 index 0000000..4cd6ccb --- /dev/null +++ b/rust/src/lib.rs @@ -0,0 +1 @@ +pub mod family_connection_context_v1; diff --git a/rust/src/main.rs b/rust/src/main.rs index ae19af5..315a44d 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -15,6 +15,8 @@ use std::env; use std::fs; use std::process::{exit, Command}; +use determa::family_connection_context_v1::RESERVED_FAMILY_COMMANDS; + const PREFIX: &str = "determa-"; fn version() -> &'static str { @@ -144,6 +146,10 @@ fn main() { println!("{}", format_product(&product, &impls)); } } + Some(sub) if RESERVED_FAMILY_COMMANDS.contains(&sub) => { + eprintln!("determa: family command '{sub}' is reserved but not implemented yet."); + exit(2); + } Some(sub) => match exe_for(sub) { Some(exe) => match Command::new(&exe).args(&args[1..]).status() { Ok(status) => exit(status.code().unwrap_or(1)), diff --git a/rust/tests/dispatch.rs b/rust/tests/dispatch.rs index 4c6f084..12531f3 100644 --- a/rust/tests/dispatch.rs +++ b/rust/tests/dispatch.rs @@ -18,12 +18,18 @@ struct StubPath { impl StubPath { fn new() -> Self { - Self { dir: TempDir::new().unwrap() } + Self { + dir: TempDir::new().unwrap(), + } } fn add(&self, name: &str, marker: &str) { let path = self.dir.path().join(name); - fs::write(&path, format!("#!/bin/sh\necho \"ran:{marker}: $*\"\nexit 0\n")).unwrap(); + fs::write( + &path, + format!("#!/bin/sh\necho \"ran:{marker}: $*\"\nexit 0\n"), + ) + .unwrap(); let mut perms = fs::metadata(&path).unwrap().permissions(); perms.set_mode(0o755); fs::set_permissions(&path, perms).unwrap(); @@ -79,6 +85,21 @@ fn unknown_product_exits_127() { assert!(err.contains("not found on PATH")); } +#[test] +fn reserved_family_commands_exit_2_before_dispatch() { + for command in ["auth", "config", "context"] { + let sp = StubPath::new(); + sp.add(&format!("determa-{command}"), "must-not-run"); + let (rc, out, err) = run(&[command], &sp.path_env(), &[]); + assert_eq!(rc, 2); + assert_eq!(out, ""); + assert_eq!( + err, + format!("determa: family command '{command}' is reserved but not implemented yet.\n") + ); + } +} + #[test] fn dispatches_to_canonical_and_propagates_exit() { let sp = StubPath::new(); @@ -89,7 +110,9 @@ fn dispatches_to_canonical_and_propagates_exit() { sp.add("determa-boom", "boom"); fs::write(sp.dir.path().join("determa-boom"), "#!/bin/sh\nexit 3\n").unwrap(); - let mut perms = fs::metadata(sp.dir.path().join("determa-boom")).unwrap().permissions(); + let mut perms = fs::metadata(sp.dir.path().join("determa-boom")) + .unwrap() + .permissions(); perms.set_mode(0o755); fs::set_permissions(sp.dir.path().join("determa-boom"), perms).unwrap(); let (rc, _, _) = run(&["boom"], &sp.path_env(), &[]); @@ -100,7 +123,11 @@ fn dispatches_to_canonical_and_propagates_exit() { fn dispatch_prefers_impl_env_var() { let sp = StubPath::new(); make_state_stubs(&sp); - let (rc, out, _) = run(&["state", "--version"], &sp.path_env(), &[("DETERMA_STATE_IMPL", "rust")]); + let (rc, out, _) = run( + &["state", "--version"], + &sp.path_env(), + &[("DETERMA_STATE_IMPL", "rust")], + ); assert_eq!(rc, 0); assert!(out.contains("ran:rust: --version"), "{out}"); } @@ -118,7 +145,11 @@ fn dispatch_env_var_unset_uses_canonical() { fn dispatch_impl_missing_falls_back_to_canonical() { let sp = StubPath::new(); sp.add("determa-state", "canonical"); // no suffixed stubs present - let (rc, out, _) = run(&["state", "ping"], &sp.path_env(), &[("DETERMA_STATE_IMPL", "rust")]); + let (rc, out, _) = run( + &["state", "ping"], + &sp.path_env(), + &[("DETERMA_STATE_IMPL", "rust")], + ); assert_eq!(rc, 0); assert!(out.contains("ran:canonical: ping"), "{out}"); } diff --git a/rust/tests/family_connection_context_v1.rs b/rust/tests/family_connection_context_v1.rs new file mode 100644 index 0000000..eeb5367 --- /dev/null +++ b/rust/tests/family_connection_context_v1.rs @@ -0,0 +1,100 @@ +use std::fs; +use std::path::PathBuf; + +use determa::family_connection_context_v1 as family; +use serde_json::Value; + +fn vector_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .join("conformance") + .join("family-connection-v1") +} + +fn load_fixture(name: &str) -> Value { + let text = fs::read_to_string(vector_root().join(name)).unwrap(); + serde_json::from_str(&text).unwrap() +} + +fn assert_case(case: &Value, operation: F) +where + F: FnOnce() -> Result, +{ + let expected = case.get("expect").unwrap(); + if let Some(error) = expected.get("error").and_then(Value::as_str) { + let actual = operation().unwrap_err(); + assert_eq!(actual.code, error, "{}", case["id"]); + } else { + assert_eq!(operation().unwrap(), expected["value"], "{}", case["id"]); + } +} + +#[test] +fn configuration_vectors() { + for case in load_fixture("configuration.json")["cases"] + .as_array() + .unwrap() + { + assert_case(case, || { + let source = case["source"].as_str().unwrap(); + family::parse_configuration_source(source) + }); + } +} + +#[test] +fn endpoint_vectors() { + for case in load_fixture("endpoints.json")["cases"].as_array().unwrap() { + assert_case(case, || { + family::canonicalize_endpoint_value(&case["input"]).map(Value::String) + }); + } +} + +#[test] +fn environment_vectors() { + let fixture = load_fixture("environment.json"); + let mut results = std::collections::BTreeMap::new(); + for case in fixture["cases"].as_array().unwrap() { + assert_case(case, || { + family::environment_name_value(&case["resource"]).map(Value::String) + }); + if case["expect"].get("value").is_some() { + results.insert( + case["id"].as_str().unwrap().to_string(), + case["expect"]["value"].as_str().unwrap().to_string(), + ); + } + } + for distinct_set in fixture["distinct_sets"].as_array().unwrap() { + let values: Vec<&String> = distinct_set + .as_array() + .unwrap() + .iter() + .map(|case_id| results.get(case_id.as_str().unwrap()).unwrap()) + .collect(); + let unique: std::collections::BTreeSet<&String> = values.iter().copied().collect(); + assert_eq!(unique.len(), values.len()); + } +} + +#[test] +fn routing_vectors() { + let fixture = load_fixture("routing.json"); + let mut configurations = std::collections::BTreeMap::new(); + for (name, source) in fixture["configurations"].as_object().unwrap() { + configurations.insert( + name.clone(), + family::parse_configuration_source(source.as_str().unwrap()).unwrap(), + ); + } + for case in fixture["cases"].as_array().unwrap() { + let configuration = configurations + .get(case["configuration"].as_str().unwrap()) + .unwrap(); + assert_case(case, || { + family::resolve_connection(configuration, &case["request"]).map(Value::String) + }); + } +} diff --git a/scripts/requirements-family-connection-v1-vectors.txt b/scripts/requirements-family-connection-v1-vectors.txt index 85dcf87..47a7a2a 100644 --- a/scripts/requirements-family-connection-v1-vectors.txt +++ b/scripts/requirements-family-connection-v1-vectors.txt @@ -1,2 +1,2 @@ -idna==3.7 \ - --hash=sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0 +idna==3.10 \ + --hash=sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3 From 4269594cd25b4a0e02b79f8fc314de85a384d84a Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Mon, 17 Aug 2026 02:29:44 +0900 Subject: [PATCH 2/5] Harden resolver dependency floors --- .github/workflows/ci.yml | 34 +++- AGENTS.md | 5 +- conformance/family-connection-v1/README.md | 2 +- docs/family-connection-context-v1.md | 6 +- python/README.md | 4 +- python/pyproject.toml | 2 +- .../determa/family_connection_context_v1.py | 147 ++++++++++++++++-- .../test_family_connection_context_v1.py | 7 + rust/Cargo.lock | 107 +------------ rust/Cargo.toml | 8 +- rust/README.md | 10 +- rust/tests/dispatch.rs | 34 +++- rust/tests/family_connection_context_v1.rs | 2 + scripts/check-rust-msrv.py | 100 ++++++++++++ 14 files changed, 330 insertions(+), 138 deletions(-) create mode 100644 scripts/check-rust-msrv.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe048f1..98f91f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,12 +48,38 @@ jobs: - uses: Swatinem/rust-cache@v2 with: workspaces: rust + - name: MSRV metadata + run: python3 ../scripts/check-rust-msrv.py . - name: Build - run: cargo build --release + run: cargo build --release --locked - name: Clippy - run: cargo clippy --release --all-targets -- -D warnings - - name: Tests - run: cargo test + run: cargo clippy --release --all-targets --all-features --locked -- -D warnings + - name: Default tests + run: cargo test --locked + - name: Repository fixture tests + run: cargo test --locked --features repository-fixtures + + rust-msrv: + name: rust launcher MSRV + runs-on: ubuntu-latest + defaults: + run: + working-directory: rust + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: dtolnay/rust-toolchain@1.81.0 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + key: msrv-1.81 + - name: MSRV metadata + run: python3 ../scripts/check-rust-msrv.py . + - name: Build + run: cargo build --release --locked + - name: Default tests + run: cargo test --locked + - name: Repository fixture tests + run: cargo test --locked --features repository-fixtures node: name: node launcher diff --git a/AGENTS.md b/AGENTS.md index bb29ed8..af4f190 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,10 @@ python3 scripts/validate-family-connection-v1-vectors.py # python cd python && pip install -e '.[dev]' && ruff check . && pytest -q # rust -cd rust && cargo build --release && cargo clippy --release --all-targets -- -D warnings && cargo test +python3 scripts/check-rust-msrv.py rust +cd rust && cargo build --release --locked && cargo clippy --release --all-targets --all-features --locked -- -D warnings && cargo test --locked && cargo test --locked --features repository-fixtures +# rust MSRV +cd rust && cargo +1.81.0 build --release --locked && cargo +1.81.0 test --locked && cargo +1.81.0 test --locked --features repository-fixtures # node cd node && npm ci && npm test ``` diff --git a/conformance/family-connection-v1/README.md b/conformance/family-connection-v1/README.md index e3c934e..337b6a4 100644 --- a/conformance/family-connection-v1/README.md +++ b/conformance/family-connection-v1/README.md @@ -67,7 +67,7 @@ python3 -m pip install --require-hashes \ 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 +The test-only IDNA path uses the hash-pinned `idna==3.10` 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 diff --git a/docs/family-connection-context-v1.md b/docs/family-connection-context-v1.md index 8986d1b..a523e32 100644 --- a/docs/family-connection-context-v1.md +++ b/docs/family-connection-context-v1.md @@ -17,7 +17,11 @@ remote transport. This document does not define a configuration-file location, credential store, network protocol, server, client, or State machine/checkpoint format. The Rust implementation uses exact behavior-relevant ICU data dependencies and a checked lockfile because the v1 endpoint profile depends on -the exact Unicode 15.1 UTS #46 boundary. +the exact Unicode 15.1 UTS #46 boundary, and declares an MSRV matching the +maximum Rust version required by the resolved normal dependency graph. The +Python implementation likewise pins `idna` tables plus `unicodedata2` 15.1 data +for normalization, category, combining-class, and bidi decisions rather than +relying on the interpreter's bundled Unicode version. ## Principles diff --git a/python/README.md b/python/README.md index 52d011a..649189b 100644 --- a/python/README.md +++ b/python/README.md @@ -20,7 +20,9 @@ The launcher is language-agnostic: it dispatches to whichever `determa-state` is `PATH`, be it the Python or the Rust build. It ships no `determa/__init__.py`, so it coexists cleanly with `determa.state` as a PEP 420 namespace. -The package also exposes the Family Connection/Context v1 resolver APIs. +The package also exposes the Family Connection/Context v1 resolver APIs. The +resolver pins `idna` and `unicodedata2` data so endpoint handling stays on the +specified Unicode 15.1 boundary across supported Python versions. ## License diff --git a/python/pyproject.toml b/python/pyproject.toml index c0761ff..821b2a4 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -19,7 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Topic :: Software Development", ] -dependencies = ["idna==3.10"] +dependencies = ["idna==3.10", "unicodedata2==15.1.0"] [project.optional-dependencies] # Convenience installs: `pip install "determa[state]"` gets the launcher + the diff --git a/python/src/determa/family_connection_context_v1.py b/python/src/determa/family_connection_context_v1.py index 733fd7c..ff967c4 100644 --- a/python/src/determa/family_connection_context_v1.py +++ b/python/src/determa/family_connection_context_v1.py @@ -6,13 +6,12 @@ import ipaddress import json import re -import unicodedata from dataclasses import dataclass from typing import Any import idna +import unicodedata2 as unicode15 from idna import idnadata, uts46data -from idna.core import valid_contextj 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}$") @@ -193,11 +192,134 @@ def validate_resource(value: Any) -> list[str]: def _uts46_status(codepoint: int) -> str: + return _uts46_row(codepoint)[1] + + +def _uts46_row(codepoint: int) -> tuple[Any, ...]: table = uts46data.uts46data - row = table[ + return table[ codepoint if codepoint < 256 else bisect.bisect_left(table, (codepoint, "Z")) - 1 ] - return row[1] + + +def _uts46_remap(domain: str) -> str: + output = "" + for position, character in enumerate(domain): + codepoint = ord(character) + try: + row = _uts46_row(codepoint) + status = row[1] + replacement = row[2] if len(row) == 3 else None + if status in {"V", "D"}: + output += character + elif replacement is not None and status == "M": + output += replacement + elif status != "I": + raise IndexError + except IndexError as exc: + raise idna.InvalidCodepoint( + f"Codepoint U+{codepoint:04X} not allowed at position " + f"{position + 1} in {domain!r}" + ) from exc + return unicode15.normalize("NFC", output) + + +def _combining_class(codepoint: int) -> int: + character = chr(codepoint) + value = unicode15.combining(character) + if value == 0 and unicode15.name(character, None) is None: + raise ValueError("Unknown character in Unicode 15.1 data") + return value + + +def _valid_contextj(label: str, position: int) -> bool: + codepoint = ord(label[position]) + + if codepoint == 0x200C: + if position > 0 and _combining_class(ord(label[position - 1])) == 9: + return True + + left_ok = False + for index in range(position - 1, -1, -1): + joining_type = idnadata.joining_types.get(ord(label[index])) + if joining_type == ord("T"): + continue + if joining_type in {ord("L"), ord("D")}: + left_ok = True + break + if not left_ok: + return False + + for index in range(position + 1, len(label)): + joining_type = idnadata.joining_types.get(ord(label[index])) + if joining_type == ord("T"): + continue + return joining_type in {ord("R"), ord("D")} + return False + + if codepoint == 0x200D: + return position > 0 and _combining_class(ord(label[position - 1])) == 9 + + return False + + +def _check_bidi(label: str, check_ltr: bool = False) -> None: + bidi_label = False + for index, character in enumerate(label, 1): + direction = unicode15.bidirectional(character) + if direction == "": + raise idna.IDNABidiError( + f"Unknown directionality in label {label!r} at position {index}" + ) + if direction in {"R", "AL", "AN"}: + bidi_label = True + if not bidi_label and not check_ltr: + return + + direction = unicode15.bidirectional(label[0]) + if direction in {"R", "AL"}: + rtl = True + elif direction == "L": + rtl = False + else: + raise idna.IDNABidiError( + f"First codepoint in label {label!r} must be directionality L, R or AL" + ) + + valid_ending = False + number_type = None + for index, character in enumerate(label, 1): + direction = unicode15.bidirectional(character) + if rtl: + if direction not in {"R", "AL", "AN", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}: + raise idna.IDNABidiError( + f"Invalid direction for codepoint at position {index} " + "in a right-to-left label" + ) + if direction in {"R", "AL", "EN", "AN"}: + valid_ending = True + elif direction != "NSM": + valid_ending = False + if direction in {"AN", "EN"}: + if number_type is None: + number_type = direction + elif number_type != direction: + raise idna.IDNABidiError( + "Can not mix numeral types in a right-to-left label" + ) + else: + if direction not in {"L", "EN", "ES", "CS", "ET", "ON", "BN", "NSM"}: + raise idna.IDNABidiError( + f"Invalid direction for codepoint at position {index} " + "in a left-to-right label" + ) + if direction in {"L", "EN"}: + valid_ending = True + elif direction != "NSM": + valid_ending = False + + if not valid_ending: + raise idna.IDNABidiError("Label ends with illegal codepoint directionality") def _validate_uts46_label(label: str) -> None: @@ -205,12 +327,12 @@ def _validate_uts46_label(label: str) -> None: raise idna.IDNAError("label must be non-empty") 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"): + if "." in label or unicode15.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): + if character in {"\u200c", "\u200d"} and not _valid_contextj(label, position): raise idna.IDNAError("label fails ContextJ") @@ -236,7 +358,7 @@ def _encode_uts46_label(label: str) -> tuple[str, str]: def _domain_to_ascii(host: str) -> str: try: - mapped = idna.uts46_remap(host, std3_rules=True, transitional=False) + mapped = _uts46_remap(host) source_labels = mapped.split(".") trailing_dot = bool(source_labels and source_labels[-1] == "") if trailing_dot: @@ -246,13 +368,13 @@ def _domain_to_ascii(host: str) -> str: 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"} + unicode15.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) + _check_bidi(label, check_ltr=True) ascii_domain = ".".join(label for _unicode_label, label in encoded) if trailing_dot: ascii_domain += "." @@ -271,11 +393,10 @@ def validate_unicode_data_version() -> None: "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): + if unicode15.unidata_version != EXPECTED_UNICODE_VERSION: raise RuntimeError( - "runtime Unicode data must be at least 15.1.0, got " - f"{unicodedata.unidata_version}" + "Unicode category, normalization, combining, and bidi data must use " + f"{EXPECTED_UNICODE_VERSION}, got {unicode15.unidata_version}" ) diff --git a/python/tests/test_family_connection_context_v1.py b/python/tests/test_family_connection_context_v1.py index 247a6eb..5821c5f 100644 --- a/python/tests/test_family_connection_context_v1.py +++ b/python/tests/test_family_connection_context_v1.py @@ -33,6 +33,13 @@ def test_unicode_data_version() -> None: family.validate_unicode_data_version() +def test_unicode_15_1_label_participates_in_bidi_domain() -> None: + assert ( + family.canonicalize_endpoint("https://\U0002EBF0.\u0646\u0627\u0645\u0647\u200c\u0627\u06cc.example/") + == "https://xn--8g0n.xn--mgba3gch31f060k.example/" + ) + + @pytest.mark.parametrize( "case", load_fixture("configuration.json")["cases"], ids=lambda case: case["id"] ) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 039ba31..1efd4ef 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1,18 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +version = 3 [[package]] name = "determa" @@ -26,7 +14,6 @@ dependencies = [ "idna_adapter", "serde", "serde_json", - "tempfile", ] [[package]] @@ -40,33 +27,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", -] - [[package]] name = "icu_collections" version = "1.5.0" @@ -212,18 +172,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.7.5" @@ -236,12 +184,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - [[package]] name = "proc-macro2" version = "1.0.107" @@ -260,25 +202,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys", -] - [[package]] name = "serde" version = "1.0.229" @@ -366,19 +289,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom", - "once_cell", - "rustix", - "windows-sys", -] - [[package]] name = "tinystr" version = "0.7.6" @@ -407,21 +317,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - [[package]] name = "write16" version = "1.0.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 03699e3..51c0965 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,7 +2,7 @@ name = "determa" version = "0.2.0" edition = "2021" -rust-version = "1.67" +rust-version = "1.81" authors = ["fruwehq"] license = "MIT" description = "Umbrella launcher for the Determa family — git-style dispatch to determa- commands" @@ -15,6 +15,9 @@ categories = ["command-line-utilities"] name = "determa" path = "src/main.rs" +[features] +repository-fixtures = [] + [dependencies] icu_normalizer = "=1.5.0" icu_normalizer_data = "=1.5.1" @@ -24,6 +27,3 @@ idna = "=1.1.0" idna_adapter = "=1.2.0" serde = "1" serde_json = "1" - -[dev-dependencies] -tempfile = "3" diff --git a/rust/README.md b/rust/README.md index ce10959..eff0451 100644 --- a/rust/README.md +++ b/rust/README.md @@ -16,12 +16,16 @@ It is language-agnostic: it dispatches to whichever `determa-state` is on `PATH` the Rust or the Python build. The crate also exposes the Family Connection/Context v1 resolver APIs. The -package MSRV is Rust 1.67 because the pinned UTS #46 backend used for the -resolver requires it. The behavior-relevant ICU data crates are exact -dependencies, and `Cargo.lock` is checked in for this binary crate so the +package MSRV is Rust 1.81, matching the maximum declared Rust version in the +resolved normal dependency graph. The behavior-relevant ICU data crates are +exact dependencies, and `Cargo.lock` is checked in for this binary crate so the verified Unicode 15.1 endpoint boundary is reproducible in CI and locked installs. +Default `cargo test` is self-contained for the packaged crate. Repository-level +shared fixtures are exercised with `cargo test --features repository-fixtures` +from this monorepo checkout. + ## License MIT diff --git a/rust/tests/dispatch.rs b/rust/tests/dispatch.rs index 12531f3..7ac2deb 100644 --- a/rust/tests/dispatch.rs +++ b/rust/tests/dispatch.rs @@ -5,11 +5,39 @@ use std::fs; use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; use std::process::Command; - -use tempfile::TempDir; +use std::sync::atomic::{AtomicUsize, Ordering}; const BIN: &str = env!("CARGO_BIN_EXE_determa"); +static TEMP_SEQUENCE: AtomicUsize = AtomicUsize::new(0); + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new() -> Self { + let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "determa-dispatch-test-{}-{sequence}", + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} /// A temp PATH populated with `determa-` stubs that print `ran:: `. struct StubPath { @@ -19,7 +47,7 @@ struct StubPath { impl StubPath { fn new() -> Self { Self { - dir: TempDir::new().unwrap(), + dir: TempDir::new(), } } diff --git a/rust/tests/family_connection_context_v1.rs b/rust/tests/family_connection_context_v1.rs index eeb5367..9a7e73a 100644 --- a/rust/tests/family_connection_context_v1.rs +++ b/rust/tests/family_connection_context_v1.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "repository-fixtures")] + use std::fs; use std::path::PathBuf; diff --git a/scripts/check-rust-msrv.py b/scripts/check-rust-msrv.py new file mode 100644 index 0000000..7ba32e9 --- /dev/null +++ b/scripts/check-rust-msrv.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Check that rust/Cargo.toml declares the resolved normal-dependency MSRV.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python < 3.11 is not used in CI. + import tomli as tomllib # type: ignore[no-redef] + + +def version_key(version: str) -> tuple[int, ...]: + return tuple(int(part) for part in version.split(".")) + + +def reaches_normal_dependencies(metadata: dict[str, object]) -> set[str]: + resolve = metadata["resolve"] + if not isinstance(resolve, dict): + raise TypeError("cargo metadata resolve must be an object") + root = resolve["root"] + nodes = { + node["id"]: node + for node in resolve["nodes"] + if isinstance(node, dict) and isinstance(node.get("id"), str) + } + seen: set[str] = set() + stack = [root] + while stack: + package_id = stack.pop() + if package_id in seen: + continue + seen.add(package_id) + node = nodes[package_id] + for dependency in node.get("deps", []): + if any(kind.get("kind") is None for kind in dependency.get("dep_kinds", [])): + stack.append(dependency["pkg"]) + return seen + + +def main() -> int: + rust_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() + manifest = rust_dir / "Cargo.toml" + declared = tomllib.loads(manifest.read_text(encoding="utf-8"))["package"].get( + "rust-version" + ) + if not isinstance(declared, str): + print("rust/Cargo.toml package.rust-version is required", file=sys.stderr) + return 1 + + metadata = json.loads( + subprocess.check_output( + ["cargo", "metadata", "--locked", "--format-version", "1"], + cwd=rust_dir, + text=True, + ) + ) + packages = {package["id"]: package for package in metadata["packages"]} + normal_package_ids = reaches_normal_dependencies(metadata) + dependency_versions = [ + ( + version_key(rust_version), + rust_version, + package["name"], + package["version"], + ) + for package_id, package in packages.items() + if package_id in normal_package_ids + and package.get("source") is not None + and isinstance((rust_version := package.get("rust_version")), str) + ] + + if not dependency_versions: + print(f"Rust MSRV declared {declared}; no dependency MSRV metadata found") + return 0 + + _key, required, name, package_version = max(dependency_versions) + if version_key(declared) < version_key(required): + print( + "rust/Cargo.toml package.rust-version " + f"{declared} is below resolved normal dependency {name} " + f"{package_version} rust-version {required}", + file=sys.stderr, + ) + return 1 + + print( + "Rust MSRV declared " + f"{declared}; maximum resolved normal dependency is {name} " + f"{package_version} requiring {required}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 7a69679898d299ea92d3fdf8017ea3b29f595dca Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Mon, 17 Aug 2026 02:34:49 +0900 Subject: [PATCH 3/5] Harden resolver Unicode and runtime support --- docs/family-connection-context-v1.md | 3 ++- node/README.md | 4 ++-- node/package-lock.json | 2 +- node/package.json | 2 +- python/README.md | 7 ++++--- python/pyproject.toml | 4 ++-- python/src/determa/_cli.py | 3 ++- python/src/determa/family_connection_context_v1.py | 1 + python/tests/test_family_connection_context_v1.py | 7 ++++++- scripts/check-rust-msrv.py | 1 - 10 files changed, 21 insertions(+), 13 deletions(-) diff --git a/docs/family-connection-context-v1.md b/docs/family-connection-context-v1.md index a523e32..26fec27 100644 --- a/docs/family-connection-context-v1.md +++ b/docs/family-connection-context-v1.md @@ -21,7 +21,8 @@ the exact Unicode 15.1 UTS #46 boundary, and declares an MSRV matching the maximum Rust version required by the resolved normal dependency graph. The Python implementation likewise pins `idna` tables plus `unicodedata2` 15.1 data for normalization, category, combining-class, and bidi decisions rather than -relying on the interpreter's bundled Unicode version. +relying on the interpreter's bundled Unicode version. The supported runtime +floors are Python 3.11, Node 22, and Rust 1.81; CI exercises each floor. ## Principles diff --git a/node/README.md b/node/README.md index 5f565fd..af296c1 100644 --- a/node/README.md +++ b/node/README.md @@ -15,8 +15,8 @@ $ determa --version It is language-agnostic: it dispatches to whichever `determa-state` is on `PATH`, be it the Node, Python, or Rust build. -The package also exposes the Family Connection/Context v1 resolver APIs. Node -18 or newer is required by the pinned UTS #46 dependency. +The package requires Node 22 or newer. It also exposes the Family +Connection/Context v1 resolver APIs through the pinned UTS #46 dependency. ## License diff --git a/node/package-lock.json b/node/package-lock.json index 4186899..06778bd 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -15,7 +15,7 @@ "determa": "bin/determa.js" }, "engines": { - "node": ">=18" + "node": ">=22" } }, "node_modules/punycode": { diff --git a/node/package.json b/node/package.json index da8a382..bf01891 100644 --- a/node/package.json +++ b/node/package.json @@ -29,7 +29,7 @@ "README.md" ], "engines": { - "node": ">=18" + "node": ">=22" }, "scripts": { "test": "node test/dispatch.test.js && node test/family-connection-context-v1.test.js" diff --git a/python/README.md b/python/README.md index 649189b..31b6500 100644 --- a/python/README.md +++ b/python/README.md @@ -20,9 +20,10 @@ The launcher is language-agnostic: it dispatches to whichever `determa-state` is `PATH`, be it the Python or the Rust build. It ships no `determa/__init__.py`, so it coexists cleanly with `determa.state` as a PEP 420 namespace. -The package also exposes the Family Connection/Context v1 resolver APIs. The -resolver pins `idna` and `unicodedata2` data so endpoint handling stays on the -specified Unicode 15.1 boundary across supported Python versions. +The package requires Python 3.11 or newer. It also exposes the Family +Connection/Context v1 resolver APIs. The resolver pins `idna` and +`unicodedata2` data so endpoint handling stays on the specified Unicode 15.1 +boundary across supported Python versions. ## License diff --git a/python/pyproject.toml b/python/pyproject.toml index 821b2a4..c39590e 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -7,7 +7,7 @@ name = "determa" version = "0.2.0" description = "Umbrella launcher for the Determa family — git-style dispatch to determa- commands" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.11" license = { file = "LICENSE" } authors = [{ name = "Christian-Manuel Butzke" }] keywords = ["determa", "cli", "launcher", "statechart", "dispatcher"] @@ -47,7 +47,7 @@ only-include = [ [tool.ruff] line-length = 100 -target-version = "py39" +target-version = "py311" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "W", "C4"] diff --git a/python/src/determa/_cli.py b/python/src/determa/_cli.py index 931dce1..f19a08c 100644 --- a/python/src/determa/_cli.py +++ b/python/src/determa/_cli.py @@ -17,8 +17,9 @@ import subprocess import sys +from determa.family_connection_context_v1 import RESERVED_FAMILY_COMMANDS + PREFIX = "determa-" -RESERVED_FAMILY_COMMANDS = frozenset({"auth", "config", "context"}) def _version() -> str: diff --git a/python/src/determa/family_connection_context_v1.py b/python/src/determa/family_connection_context_v1.py index ff967c4..a4ae1ca 100644 --- a/python/src/determa/family_connection_context_v1.py +++ b/python/src/determa/family_connection_context_v1.py @@ -26,6 +26,7 @@ UNRESERVED = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") HEX = frozenset("0123456789abcdefABCDEF") EXPECTED_UNICODE_VERSION = "15.1.0" +RESERVED_FAMILY_COMMANDS = frozenset({"auth", "config", "context"}) class FamilyConnectionError(ValueError): diff --git a/python/tests/test_family_connection_context_v1.py b/python/tests/test_family_connection_context_v1.py index 5821c5f..67dd794 100644 --- a/python/tests/test_family_connection_context_v1.py +++ b/python/tests/test_family_connection_context_v1.py @@ -3,8 +3,9 @@ from __future__ import annotations import json +from collections.abc import Callable from pathlib import Path -from typing import Any, Callable +from typing import Any import pytest @@ -33,6 +34,10 @@ def test_unicode_data_version() -> None: family.validate_unicode_data_version() +def test_reserved_family_commands_are_public() -> None: + assert family.RESERVED_FAMILY_COMMANDS == frozenset({"auth", "config", "context"}) + + def test_unicode_15_1_label_participates_in_bidi_domain() -> None: assert ( family.canonicalize_endpoint("https://\U0002EBF0.\u0646\u0627\u0645\u0647\u200c\u0627\u06cc.example/") diff --git a/scripts/check-rust-msrv.py b/scripts/check-rust-msrv.py index 7ba32e9..3fd1b21 100644 --- a/scripts/check-rust-msrv.py +++ b/scripts/check-rust-msrv.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 """Check that rust/Cargo.toml declares the resolved normal-dependency MSRV.""" from __future__ import annotations From 680c5a799c4532a8657f3b0ac46b265b6ce7b19a Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Mon, 17 Aug 2026 03:17:22 +0900 Subject: [PATCH 4/5] Address family resolver review findings --- .github/workflows/ci.yml | 17 ++- conformance/family-connection-v1/README.md | 2 +- .../family-connection-v1/configuration.json | 25 ++++ .../family-connection-v1/endpoints.json | 10 ++ conformance/family-connection-v1/routing.json | 18 +++ docs/family-connection-context-v1.md | 42 ++++++- node/bin/determa.js | 4 +- node/lib/family-connection-context-v1.js | 114 +++++++++++++++--- node/test/dispatch.test.js | 20 +++ .../test/family-connection-context-v1.test.js | 47 +++++++- python/src/determa/_cli.py | 6 +- .../determa/family_connection_context_v1.py | 85 +++++++++++-- python/tests/test_cli.py | 22 ++++ .../test_family_connection_context_v1.py | 56 ++++++++- rust/Cargo.toml | 7 +- rust/README.md | 7 +- .../family_connection_context_v1_vectors.rs} | 13 +- rust/src/family_connection_context_v1.rs | 82 +++++++++++-- rust/src/main.rs | 9 +- rust/tests/dispatch.rs | 25 ++++ .../tests/family_connection_context_v1_api.rs | 58 +++++++++ .../validate-family-connection-v1-vectors.py | 21 +++- 22 files changed, 622 insertions(+), 68 deletions(-) rename rust/{tests/family_connection_context_v1.rs => examples/family_connection_context_v1_vectors.rs} (93%) create mode 100644 rust/tests/family_connection_context_v1_api.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98f91f7..145a2db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,9 @@ jobs: working-directory: rust steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c with: + toolchain: stable components: clippy - uses: Swatinem/rust-cache@v2 with: @@ -57,7 +58,13 @@ jobs: - name: Default tests run: cargo test --locked - name: Repository fixture tests - run: cargo test --locked --features repository-fixtures + run: cargo run --locked --example family-connection-context-v1-vectors --features repository-fixtures + - name: Packaged all-features tests + run: | + cargo package --locked + mkdir -p target/package-test + tar -xzf target/package/determa-*.crate -C target/package-test + cargo test --locked --all-features --manifest-path target/package-test/determa-*/Cargo.toml rust-msrv: name: rust launcher MSRV @@ -67,7 +74,9 @@ jobs: working-directory: rust steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: dtolnay/rust-toolchain@1.81.0 + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c + with: + toolchain: 1.81.0 - uses: Swatinem/rust-cache@v2 with: workspaces: rust @@ -79,7 +88,7 @@ jobs: - name: Default tests run: cargo test --locked - name: Repository fixture tests - run: cargo test --locked --features repository-fixtures + run: cargo run --locked --example family-connection-context-v1-vectors --features repository-fixtures node: name: node launcher diff --git a/conformance/family-connection-v1/README.md b/conformance/family-connection-v1/README.md index 337b6a4..8c44c12 100644 --- a/conformance/family-connection-v1/README.md +++ b/conformance/family-connection-v1/README.md @@ -33,7 +33,7 @@ exactly one of: | 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 | +| `invalid_source` | source JSON is malformed, unavailable as a string, contains a non-JSON numeric constant, or decodes a lone surrogate | | `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 | diff --git a/conformance/family-connection-v1/configuration.json b/conformance/family-connection-v1/configuration.json index f89624b..1a9a26f 100644 --- a/conformance/family-connection-v1/configuration.json +++ b/conformance/family-connection-v1/configuration.json @@ -273,6 +273,31 @@ "id": "config-negative-infinity-source", "source": "{\"version\":-Infinity,\"connections\":{},\"contexts\":{}}", "expect": {"error": "invalid_source"} + }, + { + "id": "config-version-overflowing-exponent", + "source": "{\"version\":1e400,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_version"} + }, + { + "id": "config-version-400-digit-integer", + "source": "{\"version\":9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999,\"connections\":{},\"contexts\":{}}", + "expect": {"error": "invalid_version"} + }, + { + "id": "config-lone-surrogate-source", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{},\"default_context\":\"\\ud800\"}", + "expect": {"error": "invalid_source"} + }, + { + "id": "config-prototype-key-at-root", + "source": "{\"version\":1,\"connections\":{},\"contexts\":{},\"__proto__\":{}}", + "expect": {"error": "unknown_field"} + }, + { + "id": "config-prototype-key-in-connection", + "source": "{\"version\":1,\"connections\":{\"cloud\":{\"endpoint\":\"https://example.com\",\"__proto__\":{}}},\"contexts\":{}}", + "expect": {"error": "unknown_field"} } ] } diff --git a/conformance/family-connection-v1/endpoints.json b/conformance/family-connection-v1/endpoints.json index 232e7e4..4e6fb48 100644 --- a/conformance/family-connection-v1/endpoints.json +++ b/conformance/family-connection-v1/endpoints.json @@ -162,6 +162,11 @@ "input": "ftp://example.com/", "expect": {"error": "unsupported_endpoint_scheme"} }, + { + "id": "endpoint-query-precedes-unsupported-scheme", + "input": "ftp://example.com/?x=1", + "expect": {"error": "invalid_endpoint_syntax"} + }, { "id": "endpoint-empty-host", "input": "https:///path", @@ -262,6 +267,11 @@ "input": "https://2001:db8::1/", "expect": {"error": "invalid_endpoint_authority"} }, + { + "id": "endpoint-ipv4-suffix-not-final-32-bits", + "input": "https://[192.0.2.1::1]/", + "expect": {"error": "invalid_endpoint_host"} + }, { "id": "endpoint-ipv6-zone", "input": "http://[fe80::1%25en0]/", diff --git a/conformance/family-connection-v1/routing.json b/conformance/family-connection-v1/routing.json index 4ff5bd0..30d8b19 100644 --- a/conformance/family-connection-v1/routing.json +++ b/conformance/family-connection-v1/routing.json @@ -223,6 +223,24 @@ "configuration": "no-fallback", "request": {"resource": "state/foo_bar"}, "expect": {"error": "invalid_name"} + }, + { + "id": "routing-request-not-object", + "configuration": "no-fallback", + "request": [], + "expect": {"error": "invalid_type"} + }, + { + "id": "routing-request-missing-resource", + "configuration": "no-fallback", + "request": {}, + "expect": {"error": "missing_field"} + }, + { + "id": "routing-request-unknown-field", + "configuration": "no-fallback", + "request": {"resource": "state", "extra": true}, + "expect": {"error": "unknown_field"} } ] } diff --git a/docs/family-connection-context-v1.md b/docs/family-connection-context-v1.md index 26fec27..fae9a7e 100644 --- a/docs/family-connection-context-v1.md +++ b/docs/family-connection-context-v1.md @@ -43,6 +43,11 @@ logical model. Its file syntax, location, and discovery rules are intentionally unspecified in v1. Concrete syntaxes MUST preserve the value types below and MUST reject duplicate map keys before constructing this model. +A configuration source MUST be valid JSON whose object keys and string values +decode to Unicode scalar-value sequences. A source containing an escaped or +unescaped lone UTF-16 surrogate is `invalid_source`, even on a host language +whose string type can represent lone surrogates. + ```yaml version: 1 default_context: personal @@ -144,11 +149,23 @@ A source document containing two `cloud` keys in the same `connections` map is invalid before model construction. A parser MUST NOT keep the first value, keep the last value, or silently merge the two connection objects. +The public parser and decoded-value validator MUST return an opaque, +implementation-defined `ValidatedConfiguration`, not expose the normalized +model as mutable configuration state. The validated value MUST be immutable or +defensively isolated from every caller-owned input and exported copy. A public +conversion back to the JSON-shaped logical model MAY return a fresh copy. +Resolution MUST accept only a `ValidatedConfiguration`; any other value and any +malformed request MUST fail with the error codes in this contract rather than a +language-native exception or panic. A decoded-value validator accepts ordinary +JSON values, including numeric integer `1` for `version`; source-only numeric +token distinctions are enforced by the source parser before model construction. + ## Canonical endpoints Each connection has exactly one `endpoint`. A configuration reader MUST apply the ordered algorithm below before comparison, routing, or persistence. The -result is an ASCII absolute URI under RFC 3986. Implementations MUST implement +first failing numbered step determines the error; later failures MUST NOT +replace it. The result is an ASCII absolute URI under RFC 3986. Implementations MUST implement this profile directly or prove their URL library produces the same result; a library's platform-dependent URL normalization is not normative. @@ -181,10 +198,11 @@ library's platform-dependent URL normalization is not normative. accepted by some URL libraries. - A bracketed IPv6 literal MUST parse under RFC 4291 section 2.2 and MUST be emitted in brackets using RFC 5952 sections 4.1 through 4.3. Always emit - all 128 bits in hexadecimal form; an accepted IPv4-embedded input such as + all 128 bits in hexadecimal form. A dotted-decimal IPv4 suffix is accepted + only when it supplies the final 32 bits of the IPv6 address; for example, `::ffff:192.0.2.1` is emitted as `::ffff:c000:201`, never with dotted - decimal. IPvFuture literals are invalid. Zone identifiers, including - RFC 6874 `%25zone` syntax, are invalid. + decimal, while `192.0.2.1::1` is invalid. IPvFuture literals are invalid. + Zone identifiers, including RFC 6874 `%25zone` syntax, are invalid. 5. A port, when present, MUST contain only ASCII decimal digits, begin with `1` through `9`, and have value 1 through 65535. Remove port 443 for `https` and port 80 for `http`; emit every other port as its shortest decimal form. @@ -265,6 +283,14 @@ from this v1 document. ## Resolution precedence +A resolver request is a closed JSON-shaped object. It requires string field +`resource` and permits only optional `explicit_connection`, `environment`, and +`selected_context` fields. `explicit_connection` and `selected_context` MUST be +strings when present. `environment` MUST be a map of string keys to string +values. A non-object request is `invalid_type`, a missing `resource` is +`missing_field`, and any other field is `unknown_field`; these structural checks +precede the resolution steps below. + For a request for product/resource `R`, connection resolution is exactly: 1. An explicit per-request connection override. @@ -328,6 +354,10 @@ Node launcher implementation MUST recognize them before product dispatch. Their subcommands, flags, output, and persistence behavior are not implemented or specified here. +Product discovery, `determa list`, and launcher help MUST omit executables +whose product stem is one of these reserved names, including +implementation-suffixed forms such as `determa-config-rust`. + Until command syntax is specified, invoking `determa config`, `determa context`, or `determa auth` MUST fail locally before product dispatch with exit status `2`, empty stdout, and stderr exactly: @@ -388,12 +418,12 @@ The following work is intentionally excluded from v1 and requires separate issues and review before implementation: - Configuration-file discovery, editing, and credential-provider behavior. -- Launcher parsing and behavior for the reserved family commands. +- Subcommand syntax and behavior beneath the reserved family commands. - A versioned managed/self-hosted protocol, capability discovery, closed error envelopes, resource identity, authentication rules, idempotency, concurrency, pagination, TLS, and redirect rules. - Language-specific `DetermaClient` packages and transport implementations. -- Shared routing conformance vectors and protocol conformance. +- Protocol conformance beyond the shared local resolver vectors. - Durable host-owned route-binding/outbox schemas and SaaS control-plane work. - State specification, engine, checkpoint, store, socket, MCP, or example changes. diff --git a/node/bin/determa.js b/node/bin/determa.js index 0d76108..c4d8074 100644 --- a/node/bin/determa.js +++ b/node/bin/determa.js @@ -61,7 +61,9 @@ function splitVariant(stem, all) { // Products -> sorted impl variants (canonical products map to []). Array of [product, impls]. function discover() { - const all = stems(); + const all = new Set( + [...stems()].filter(stem => !RESERVED_FAMILY_COMMANDS.has(stem.split("-", 1)[0])) + ); const products = new Map(); for (const stem of all) { const [product, impl] = splitVariant(stem, all); diff --git a/node/lib/family-connection-context-v1.js b/node/lib/family-connection-context-v1.js index 75173c7..a983f04 100644 --- a/node/lib/family-connection-context-v1.js +++ b/node/lib/family-connection-context-v1.js @@ -31,6 +31,23 @@ class NumberToken { } } +const VALIDATED_CONFIGURATION_TOKEN = Symbol("validated-configuration"); +const validatedModels = new WeakMap(); + +class ValidatedConfiguration { + constructor(model, token) { + if (token !== VALIDATED_CONFIGURATION_TOKEN) fail("invalid_type"); + validatedModels.set(this, model); + Object.freeze(this); + } + + toValue() { + const model = validatedModels.get(this); + if (!model) fail("invalid_type"); + return cloneJsonValue(model); + } +} + function fail(code) { throw new FamilyConnectionError(code); } @@ -73,7 +90,7 @@ class JsonParser { parseObject() { this.index++; - const result = {}; + const result = Object.create(null); const seen = new Set(); this.skipWhitespace(); if (this.source[this.index] === "}") { @@ -222,7 +239,7 @@ class JsonParser { function parseConfigurationSource(source) { if (typeof source !== "string") fail("invalid_source"); - return validateConfiguration(new JsonParser(source).parse()); + return validateConfigurationValue(new JsonParser(source).parse(), true); } function isObject(value) { @@ -267,7 +284,7 @@ function validateRoutes(value, connectionNames) { if (!isObject(value) || value instanceof IntegerToken || value instanceof NumberToken) { fail("invalid_type"); } - const result = {}; + const result = Object.create(null); for (const [resource, connection] of Object.entries(value)) { validateResource(resource); if (typeof connection !== "string") fail("invalid_type"); @@ -278,17 +295,47 @@ function validateRoutes(value, connectionNames) { } function validateConfiguration(value) { + assertJsonUnicodeScalars(value); + return validateConfigurationValue(value, false); +} + +function assertJsonUnicodeScalars(value) { + if (typeof value === "string") { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) fail("invalid_source"); + index++; + } else if (code >= 0xdc00 && code <= 0xdfff) { + fail("invalid_source"); + } + } + } else if (Array.isArray(value)) { + for (const item of value) assertJsonUnicodeScalars(item); + } else if (isObject(value)) { + for (const [key, item] of Object.entries(value)) { + assertJsonUnicodeScalars(key); + assertJsonUnicodeScalars(item); + } + } +} + +function validateConfigurationValue(value, sourceTokens) { const root = requireClosedObject( value, new Set(["version", "connections", "contexts"]), new Set(["default_context", "defaults"]) ); - if (!(root.version instanceof IntegerToken) || root.version.source !== "1") { + const validVersion = sourceTokens + ? root.version instanceof IntegerToken && root.version.source === "1" + : typeof root.version === "number" && Number.isInteger(root.version) && root.version === 1; + if (!validVersion) { fail("invalid_version"); } if (!isObject(root.connections)) fail("invalid_type"); - const connections = {}; + const connections = Object.create(null); for (const [rawName, rawConnection] of Object.entries(root.connections)) { const name = requireName(rawName); const connection = requireClosedObject( @@ -296,31 +343,34 @@ function validateConfiguration(value) { new Set(["endpoint"]), new Set(["credential_ref"]) ); - const normalized = { endpoint: canonicalizeEndpoint(connection.endpoint) }; + const normalized = Object.create(null); + normalized.endpoint = canonicalizeEndpoint(connection.endpoint); if (Object.prototype.hasOwnProperty.call(connection, "credential_ref")) { const credential = requireClosedObject( connection.credential_ref, new Set(["provider", "name"]), new Set() ); - normalized.credential_ref = { + normalized.credential_ref = Object.assign(Object.create(null), { provider: requireName(credential.provider), name: requireNonemptyString(credential.name), - }; + }); } connections[name] = normalized; } const connectionNames = new Set(Object.keys(connections)); if (!isObject(root.contexts)) fail("invalid_type"); - const contexts = {}; + const contexts = Object.create(null); for (const [rawName, rawContext] of Object.entries(root.contexts)) { const name = requireName(rawName); const context = requireClosedObject(rawContext, new Set(["routes"]), new Set()); - contexts[name] = { routes: validateRoutes(context.routes, connectionNames) }; + contexts[name] = Object.assign(Object.create(null), { + routes: validateRoutes(context.routes, connectionNames), + }); } - const result = { version: 1, connections, contexts }; + const result = Object.assign(Object.create(null), { version: 1, connections, contexts }); if (Object.prototype.hasOwnProperty.call(root, "default_context")) { const defaultContext = requireName(root.default_context); if (!Object.prototype.hasOwnProperty.call(contexts, defaultContext)) fail("invalid_reference"); @@ -328,11 +378,28 @@ function validateConfiguration(value) { } if (Object.prototype.hasOwnProperty.call(root, "defaults")) { const defaults = requireClosedObject(root.defaults, new Set(["routes"]), new Set()); - result.defaults = { routes: validateRoutes(defaults.routes, connectionNames) }; + result.defaults = Object.assign(Object.create(null), { + routes: validateRoutes(defaults.routes, connectionNames), + }); } + freezeJsonValue(result); + return new ValidatedConfiguration(result, VALIDATED_CONFIGURATION_TOKEN); +} + +function cloneJsonValue(value) { + if (Array.isArray(value)) return value.map(cloneJsonValue); + if (!isObject(value)) return value; + const result = Object.create(null); + for (const [key, item] of Object.entries(value)) result[key] = cloneJsonValue(item); return result; } +function freezeJsonValue(value) { + if (!isObject(value) && !Array.isArray(value)) return value; + for (const item of Object.values(value)) freezeJsonValue(item); + return Object.freeze(value); +} + function assertValidEndpointScalars(value) { for (let index = 0; index < value.length; index++) { const code = value.charCodeAt(index); @@ -364,6 +431,9 @@ function parseIpv6Part(part) { function parseIpv6(rawHost) { if (!rawHost || rawHost.includes("%")) fail("invalid_endpoint_host"); if ((rawHost.match(/::/g) || []).length > 1) fail("invalid_endpoint_host"); + if (rawHost.includes(".") && !/(?:^|:)(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){3}$/.test(rawHost)) { + fail("invalid_endpoint_host"); + } const hasCompression = rawHost.includes("::"); const [leftText, rightText = ""] = rawHost.split("::"); @@ -615,9 +685,16 @@ function firstRoute(routes, resource) { } function resolveConnection(configuration, request) { + const model = validatedModels.get(configuration); + if (!model) fail("invalid_type"); + request = requireClosedObject( + request, + new Set(["resource"]), + new Set(["explicit_connection", "environment", "selected_context"]) + ); const resource = request.resource; validateResource(resource); - const connections = configuration.connections; + const connections = model.connections; if (Object.prototype.hasOwnProperty.call(request, "explicit_connection")) { const explicit = request.explicit_connection; @@ -648,20 +725,20 @@ function resolveConnection(configuration, request) { if (Object.prototype.hasOwnProperty.call(request, "selected_context")) { const selected = request.selected_context; - if (typeof selected !== "string" || !Object.prototype.hasOwnProperty.call(configuration.contexts, selected)) { + if (typeof selected !== "string" || !Object.prototype.hasOwnProperty.call(model.contexts, selected)) { fail("invalid_context"); } - const connection = firstRoute(configuration.contexts[selected].routes, resource); + const connection = firstRoute(model.contexts[selected].routes, resource); if (connection !== null) return connection; } - const defaults = configuration.defaults || { routes: {} }; + const defaults = model.defaults || { routes: {} }; const defaultConnection = firstRoute(defaults.routes, resource); if (defaultConnection !== null) return defaultConnection; - const defaultContext = configuration.default_context; + const defaultContext = model.default_context; if (defaultContext !== undefined) { - const connection = firstRoute(configuration.contexts[defaultContext].routes, resource); + const connection = firstRoute(model.contexts[defaultContext].routes, resource); if (connection !== null) return connection; } fail("unresolved_connection"); @@ -670,6 +747,7 @@ function resolveConnection(configuration, request) { module.exports = { FamilyConnectionError, RESERVED_FAMILY_COMMANDS, + ValidatedConfiguration, canonicalizeEndpoint, environmentName, parseConfigurationSource, diff --git a/node/test/dispatch.test.js b/node/test/dispatch.test.js index 5a11fe1..3d4339b 100644 --- a/node/test/dispatch.test.js +++ b/node/test/dispatch.test.js @@ -102,6 +102,26 @@ if (process.platform !== "win32") { r = run(["--help"], { PATH: stateDir }); assert.ok(r.stdout.includes("state (python, rust)"), r.stdout); assert.ok(r.stdout.includes("DETERMA__IMPL")); + + const reservedDir = fs.mkdtempSync(path.join(os.tmpdir(), "determa-discovery-")); + for (const name of [ + "determa-alpha", + "determa-auth", + "determa-config", + "determa-config-rust", + "determa-context", + ]) { + const executable = path.join(reservedDir, name); + fs.writeFileSync(executable, "#!/bin/sh\n"); + fs.chmodSync(executable, 0o755); + } + r = run(["list"], { PATH: reservedDir }); + assert.strictEqual(r.stdout, "alpha\n"); + r = run(["--help"], { PATH: reservedDir }); + assert.ok(r.stdout.includes(" alpha")); + assert.ok(!r.stdout.includes(" auth")); + assert.ok(!r.stdout.includes(" config")); + assert.ok(!r.stdout.includes(" context")); } console.log("determa node launcher: all tests passed"); diff --git a/node/test/family-connection-context-v1.test.js b/node/test/family-connection-context-v1.test.js index 6a73c7c..4132c7e 100644 --- a/node/test/family-connection-context-v1.test.js +++ b/node/test/family-connection-context-v1.test.js @@ -21,7 +21,9 @@ function assertCase(case_, operation) { case_.id ); } else { - assert.deepStrictEqual(operation(), case_.expect.value, case_.id); + let value = operation(); + if (value instanceof family.ValidatedConfiguration) value = value.toValue(); + assert.deepStrictEqual(JSON.parse(JSON.stringify(value)), case_.expect.value, case_.id); } } @@ -56,4 +58,45 @@ for (const case_ of routingFixture.cases) { assertCase(case_, () => family.resolveConnection(configuration, case_.request)); } -console.log("family connection/context v1 node vectors: 152 passed"); +const decoded = { + version: 1, + connections: { cloud: { endpoint: "https://example.com" } }, + contexts: {}, +}; +const validated = family.validateConfiguration(decoded); +decoded.connections.cloud.endpoint = "https://changed.example"; +const exported = validated.toValue(); +delete exported.connections.cloud; +assert.strictEqual( + family.resolveConnection(validated, { + resource: "state", + explicit_connection: "cloud", + }), + "cloud" +); + +for (const malformed of [null, {}, [], "configuration"]) { + assert.throws( + () => family.resolveConnection(malformed, { resource: "state" }), + error => error instanceof family.FamilyConnectionError && error.code === "invalid_type" + ); +} +for (const malformed of [null, [], "configuration", 1]) { + assert.throws( + () => family.validateConfiguration(malformed), + error => error instanceof family.FamilyConnectionError && error.code === "invalid_type" + ); +} +for (const malformed of [null, [], "state", 1]) { + assert.throws( + () => family.resolveConnection(validated, malformed), + error => error instanceof family.FamilyConnectionError && error.code === "invalid_type" + ); +} + +const vectorCount = + loadFixture("configuration.json").cases.length + + loadFixture("endpoints.json").cases.length + + environmentFixture.cases.length + + routingFixture.cases.length; +console.log(`family connection/context v1 node vectors: ${vectorCount} passed`); diff --git a/python/src/determa/_cli.py b/python/src/determa/_cli.py index f19a08c..56b092b 100644 --- a/python/src/determa/_cli.py +++ b/python/src/determa/_cli.py @@ -79,7 +79,11 @@ def _discover() -> dict[str, list[str]]: ``determa-state-python`` and ``determa-state-rust`` installed, returns ``{"state": ["python", "rust"]}``. """ - stems = _stems() + stems = { + stem + for stem in _stems() + if stem.split("-", 1)[0] not in RESERVED_FAMILY_COMMANDS + } products: dict[str, set[str]] = {} for stem in stems: product, impl = _split_variant(stem, stems) diff --git a/python/src/determa/family_connection_context_v1.py b/python/src/determa/family_connection_context_v1.py index a4ae1ca..83a4eb5 100644 --- a/python/src/determa/family_connection_context_v1.py +++ b/python/src/determa/family_connection_context_v1.py @@ -47,6 +47,28 @@ class NonIntegerNumberToken: source: str +_VALIDATED_CONFIGURATION_TOKEN = object() + + +class ValidatedConfiguration: + """Opaque, immutable validated Family Connection/Context v1 configuration.""" + + __slots__ = ("__serialized",) + + def __init__(self, serialized: str, token: object = None): + if token is not _VALIDATED_CONFIGURATION_TOKEN: + raise _error("invalid_type") + self.__serialized = serialized + + def to_value(self) -> dict[str, Any]: + """Return a fresh JSON-shaped copy of the normalized configuration.""" + + return json.loads(self.__serialized) + + def _model(self) -> dict[str, Any]: + return json.loads(self.__serialized) + + def _error(code: str) -> FamilyConnectionError: return FamilyConnectionError(code) @@ -64,7 +86,7 @@ def _reject_nonfinite_constant(_value: str) -> Any: raise _error("invalid_source") -def parse_configuration_source(source: Any) -> dict[str, Any]: +def parse_configuration_source(source: Any) -> ValidatedConfiguration: """Parse and validate a JSON source string into the closed logical model.""" if not isinstance(source, str): @@ -81,7 +103,23 @@ def parse_configuration_source(source: Any) -> dict[str, Any]: raise except (json.JSONDecodeError, UnicodeError) as exc: raise _error("invalid_source") from exc - return validate_configuration(value) + _require_unicode_scalars(value) + return _validate_configuration(value, source_tokens=True) + + +def _require_unicode_scalars(value: Any) -> None: + if isinstance(value, str): + if any(0xD800 <= ord(character) <= 0xDFFF for character in value): + raise _error("invalid_source") + return + if isinstance(value, list): + for item in value: + _require_unicode_scalars(item) + return + if isinstance(value, dict): + for key, item in value.items(): + _require_unicode_scalars(key) + _require_unicode_scalars(item) def _require_closed_object(value: Any, required: set[str], optional: set[str]) -> dict[str, Any]: @@ -124,16 +162,28 @@ def _validate_routes(value: Any, connection_names: set[str]) -> dict[str, str]: return result -def validate_configuration(value: Any) -> dict[str, Any]: +def validate_configuration(value: Any) -> ValidatedConfiguration: """Validate a decoded JSON-like configuration value.""" + _require_unicode_scalars(value) + return _validate_configuration(value, source_tokens=False) + + +def _validate_configuration( + value: Any, *, source_tokens: bool +) -> ValidatedConfiguration: root = _require_closed_object( value, {"version", "connections", "contexts"}, {"default_context", "defaults"}, ) version = root["version"] - if not isinstance(version, IntegerToken) or version.source != "1": + valid_version = ( + isinstance(version, IntegerToken) and version.source == "1" + if source_tokens + else type(version) is int and version == 1 + ) + if not valid_version: raise _error("invalid_version") raw_connections = root["connections"] @@ -180,7 +230,8 @@ def validate_configuration(value: Any) -> dict[str, Any]: normalized_root["defaults"] = { "routes": _validate_routes(defaults["routes"], connection_names) } - return normalized_root + serialized = json.dumps(normalized_root, ensure_ascii=False, separators=(",", ":")) + return ValidatedConfiguration(serialized, _VALIDATED_CONFIGURATION_TOKEN) def validate_resource(value: Any) -> list[str]: @@ -634,12 +685,20 @@ def _first_route(routes: dict[str, str], resource: str) -> Any: return None -def resolve_connection(configuration: dict[str, Any], request: dict[str, Any]) -> str: +def resolve_connection(configuration: Any, request: Any) -> str: """Resolve one request to a named connection using exact v1 precedence.""" - resource = request.get("resource") + if not isinstance(configuration, ValidatedConfiguration): + raise _error("invalid_type") + request = _require_closed_object( + request, + {"resource"}, + {"explicit_connection", "environment", "selected_context"}, + ) + model = configuration._model() + resource = request["resource"] validate_resource(resource) - connections = configuration["connections"] + connections = model["connections"] if "explicit_connection" in request: explicit = request["explicit_connection"] @@ -668,20 +727,20 @@ def resolve_connection(configuration: dict[str, Any], request: dict[str, Any]) - if "selected_context" in request: selected = request["selected_context"] - if not isinstance(selected, str) or selected not in configuration["contexts"]: + if not isinstance(selected, str) or selected not in model["contexts"]: raise _error("invalid_context") - connection = _first_route(configuration["contexts"][selected]["routes"], resource) + connection = _first_route(model["contexts"][selected]["routes"], resource) if connection is not None: return connection - defaults = configuration.get("defaults", {"routes": {}}) + defaults = model.get("defaults", {"routes": {}}) connection = _first_route(defaults["routes"], resource) if connection is not None: return connection - default_context = configuration.get("default_context") + default_context = model.get("default_context") if default_context is not None: - connection = _first_route(configuration["contexts"][default_context]["routes"], resource) + connection = _first_route(model["contexts"][default_context]["routes"], resource) if connection is not None: return connection raise _error("unresolved_connection") diff --git a/python/tests/test_cli.py b/python/tests/test_cli.py index e07c941..ef5f145 100644 --- a/python/tests/test_cli.py +++ b/python/tests/test_cli.py @@ -79,6 +79,28 @@ def test_list_discovers_products(tmp_path, monkeypatch, capsys): assert capsys.readouterr().out.split() == ["alpha", "beta"] +@pytest.mark.skipif(sys.platform == "win32", reason="uses POSIX executable stubs") +def test_reserved_family_commands_are_hidden_from_discovery(tmp_path, monkeypatch, capsys): + for name in ( + "determa-alpha", + "determa-auth", + "determa-config", + "determa-config-rust", + "determa-context", + ): + executable = tmp_path / name + executable.write_text("#!/bin/sh\n") + executable.chmod(0o755) + monkeypatch.setenv("PATH", str(tmp_path)) + + assert cli.main(["list"]) == 0 + assert capsys.readouterr().out.splitlines() == ["alpha"] + assert cli.main(["--help"]) == 0 + help_output = capsys.readouterr().out + assert " alpha" in help_output + assert all(f" {name}" not in help_output for name in ("auth", "config", "context")) + + # --- implementation selection (DETERMA__IMPL) --------------------- def _make_state_stubs(tmp_path): diff --git a/python/tests/test_family_connection_context_v1.py b/python/tests/test_family_connection_context_v1.py index 67dd794..106cc61 100644 --- a/python/tests/test_family_connection_context_v1.py +++ b/python/tests/test_family_connection_context_v1.py @@ -27,7 +27,10 @@ def assert_case(case: dict[str, Any], operation: Callable[[], Any]) -> None: operation() assert exc_info.value.code == expected["error"] else: - assert operation() == expected["value"] + value = operation() + if isinstance(value, family.ValidatedConfiguration): + value = value.to_value() + assert value == expected["value"] def test_unicode_data_version() -> None: @@ -79,7 +82,7 @@ def test_environment_distinct_sets() -> None: @pytest.fixture(scope="module") -def routing_configurations() -> dict[str, dict[str, Any]]: +def routing_configurations() -> dict[str, family.ValidatedConfiguration]: fixture = load_fixture("routing.json") return { name: family.parse_configuration_source(source) @@ -89,7 +92,54 @@ def routing_configurations() -> dict[str, dict[str, Any]]: @pytest.mark.parametrize("case", load_fixture("routing.json")["cases"], ids=lambda case: case["id"]) def test_routing_vectors( - case: dict[str, Any], routing_configurations: dict[str, dict[str, Any]] + case: dict[str, Any], + routing_configurations: dict[str, family.ValidatedConfiguration], ) -> None: configuration = routing_configurations[case["configuration"]] assert_case(case, lambda: family.resolve_connection(configuration, case["request"])) + + +def test_decoded_configuration_is_opaque_and_isolated_from_mutation() -> None: + source = { + "version": 1, + "connections": {"cloud": {"endpoint": "https://example.com"}}, + "contexts": {}, + } + configuration = family.validate_configuration(source) + source["connections"]["cloud"]["endpoint"] = "https://changed.example" + exported = configuration.to_value() + exported["connections"].clear() + + assert ( + family.resolve_connection( + configuration, + {"resource": "state", "explicit_connection": "cloud"}, + ) + == "cloud" + ) + + +@pytest.mark.parametrize("configuration", [None, {}, []]) +def test_resolver_rejects_unvalidated_configuration(configuration: Any) -> None: + with pytest.raises(family.FamilyConnectionError) as exc_info: + family.resolve_connection(configuration, {"resource": "state"}) + assert exc_info.value.code == "invalid_type" + + +@pytest.mark.parametrize("value", [None, [], "configuration", 1]) +def test_validator_rejects_non_object_input_with_family_error(value: Any) -> None: + with pytest.raises(family.FamilyConnectionError) as exc_info: + family.validate_configuration(value) + assert exc_info.value.code == "invalid_type" + + +@pytest.mark.parametrize("request_value", [None, [], "state", 1]) +def test_resolver_rejects_malformed_request_without_native_exception( + request_value: Any, +) -> None: + configuration = family.validate_configuration( + {"version": 1, "connections": {}, "contexts": {}} + ) + with pytest.raises(family.FamilyConnectionError) as exc_info: + family.resolve_connection(configuration, request_value) + assert exc_info.value.code == "invalid_type" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 51c0965..f3d1274 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -15,6 +15,11 @@ categories = ["command-line-utilities"] name = "determa" path = "src/main.rs" +[[example]] +name = "family-connection-context-v1-vectors" +path = "examples/family_connection_context_v1_vectors.rs" +required-features = ["repository-fixtures"] + [features] repository-fixtures = [] @@ -26,4 +31,4 @@ icu_properties_data = "=1.5.1" idna = "=1.1.0" idna_adapter = "=1.2.0" serde = "1" -serde_json = "1" +serde_json = { version = "1", features = ["arbitrary_precision"] } diff --git a/rust/README.md b/rust/README.md index eff0451..4e8bc17 100644 --- a/rust/README.md +++ b/rust/README.md @@ -22,9 +22,10 @@ exact dependencies, and `Cargo.lock` is checked in for this binary crate so the verified Unicode 15.1 endpoint boundary is reproducible in CI and locked installs. -Default `cargo test` is self-contained for the packaged crate. Repository-level -shared fixtures are exercised with `cargo test --features repository-fixtures` -from this monorepo checkout. +All crate tests, including `cargo test --all-features`, are self-contained in +the published package. Repository-level shared fixtures are exercised from this +monorepo checkout with +`cargo run --example family-connection-context-v1-vectors --features repository-fixtures`. ## License diff --git a/rust/tests/family_connection_context_v1.rs b/rust/examples/family_connection_context_v1_vectors.rs similarity index 93% rename from rust/tests/family_connection_context_v1.rs rename to rust/examples/family_connection_context_v1_vectors.rs index 9a7e73a..031500e 100644 --- a/rust/tests/family_connection_context_v1.rs +++ b/rust/examples/family_connection_context_v1_vectors.rs @@ -32,7 +32,6 @@ where } } -#[test] fn configuration_vectors() { for case in load_fixture("configuration.json")["cases"] .as_array() @@ -40,12 +39,11 @@ fn configuration_vectors() { { assert_case(case, || { let source = case["source"].as_str().unwrap(); - family::parse_configuration_source(source) + family::parse_configuration_source(source).map(|configuration| configuration.to_value()) }); } } -#[test] fn endpoint_vectors() { for case in load_fixture("endpoints.json")["cases"].as_array().unwrap() { assert_case(case, || { @@ -54,7 +52,6 @@ fn endpoint_vectors() { } } -#[test] fn environment_vectors() { let fixture = load_fixture("environment.json"); let mut results = std::collections::BTreeMap::new(); @@ -81,7 +78,6 @@ fn environment_vectors() { } } -#[test] fn routing_vectors() { let fixture = load_fixture("routing.json"); let mut configurations = std::collections::BTreeMap::new(); @@ -100,3 +96,10 @@ fn routing_vectors() { }); } } + +fn main() { + configuration_vectors(); + endpoint_vectors(); + environment_vectors(); + routing_vectors(); +} diff --git a/rust/src/family_connection_context_v1.rs b/rust/src/family_connection_context_v1.rs index 1f1595c..f3439d5 100644 --- a/rust/src/family_connection_context_v1.rs +++ b/rust/src/family_connection_context_v1.rs @@ -31,6 +31,17 @@ impl std::error::Error for FamilyConnectionError {} type Result = std::result::Result; +#[derive(Debug, Clone, PartialEq)] +pub struct ValidatedConfiguration { + value: Value, +} + +impl ValidatedConfiguration { + pub fn to_value(&self) -> Value { + self.value.clone() + } +} + #[derive(Debug, Clone)] enum JsonValue { Null, @@ -119,15 +130,49 @@ impl<'de> Deserialize<'de> for JsonValue { } } -pub fn parse_configuration_source(source: &str) -> Result { - let parsed = serde_json::from_str::(source).map_err(|error| { +pub fn parse_configuration_source(source: &str) -> Result { + serde_json::from_str::(source).map_err(|error| { if error.to_string().contains("duplicate_key") { FamilyConnectionError::new("duplicate_key") } else { FamilyConnectionError::new("invalid_source") } })?; - validate_configuration(&parsed) + let parsed = serde_json::from_str::(source) + .map_err(|_| FamilyConnectionError::new("invalid_source"))?; + normalize_configuration(&json_model(&parsed)).map(|value| ValidatedConfiguration { value }) +} + +pub fn validate_configuration(value: &Value) -> Result { + normalize_configuration(&json_model(value)).map(|value| ValidatedConfiguration { value }) +} + +fn json_model(value: &Value) -> JsonValue { + match value { + Value::Null => JsonValue::Null, + Value::Bool(_) => JsonValue::Bool, + Value::Number(number) => { + let source = number.to_string(); + if source + .strip_prefix('-') + .unwrap_or(&source) + .chars() + .all(|character| character.is_ascii_digit()) + { + JsonValue::Integer(source) + } else { + JsonValue::Number + } + } + Value::String(value) => JsonValue::String(value.clone()), + Value::Array(_) => JsonValue::Array, + Value::Object(values) => JsonValue::Object( + values + .iter() + .map(|(key, value)| (key.clone(), json_model(value))) + .collect(), + ), + } } fn object<'a>( @@ -211,7 +256,7 @@ fn validate_routes( Ok(result) } -fn validate_configuration(value: &JsonValue) -> Result { +fn normalize_configuration(value: &JsonValue) -> Result { let root = object( value, &["version", "connections", "contexts"], @@ -321,13 +366,13 @@ pub fn canonicalize_endpoint(value: &str) -> Result { if !valid_scheme(&scheme) { return Err(FamilyConnectionError::new("invalid_endpoint_syntax")); } - if scheme != "http" && scheme != "https" { - return Err(FamilyConnectionError::new("unsupported_endpoint_scheme")); - } let after_scheme = &value[scheme_end + 3..]; if after_scheme.contains('?') || after_scheme.contains('#') { return Err(FamilyConnectionError::new("invalid_endpoint_syntax")); } + if scheme != "http" && scheme != "https" { + return Err(FamilyConnectionError::new("unsupported_endpoint_scheme")); + } let slash = after_scheme.find('/'); let (authority, raw_path) = match slash { Some(index) => (&after_scheme[..index], &after_scheme[index..]), @@ -712,15 +757,36 @@ fn first_route(routes: &Map, resource: &str) -> Option { .find_map(|key| routes.get(key).and_then(Value::as_str).map(str::to_string)) } -pub fn resolve_connection(configuration: &Value, request: &Value) -> Result { +pub fn resolve_connection( + configuration: &ValidatedConfiguration, + request: &Value, +) -> Result { let request = request .as_object() .ok_or_else(|| FamilyConnectionError::new("invalid_type"))?; + for required in ["resource"] { + if !request.contains_key(required) { + return Err(FamilyConnectionError::new("missing_field")); + } + } + for field in request.keys() { + if ![ + "resource", + "explicit_connection", + "environment", + "selected_context", + ] + .contains(&field.as_str()) + { + return Err(FamilyConnectionError::new("unknown_field")); + } + } let resource = request .get("resource") .and_then(Value::as_str) .ok_or_else(|| FamilyConnectionError::new("invalid_type"))?; validate_resource(resource)?; + let configuration = &configuration.value; let connections = configuration .get("connections") .and_then(Value::as_object) diff --git a/rust/src/main.rs b/rust/src/main.rs index 315a44d..55bea05 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -63,7 +63,14 @@ fn split_variant(stem: &str, all: &BTreeSet) -> (String, Option) /// Products → sorted implementation variants on `PATH` (canonical products map to empty). fn discover() -> Vec<(String, Vec)> { - let all = stems(); + let all: BTreeSet = stems() + .into_iter() + .filter(|stem| { + !RESERVED_FAMILY_COMMANDS + .iter() + .any(|reserved| stem.split('-').next() == Some(*reserved)) + }) + .collect(); let mut products: BTreeMap> = BTreeMap::new(); for stem in &all { let (product, impl_) = split_variant(stem, &all); diff --git a/rust/tests/dispatch.rs b/rust/tests/dispatch.rs index 7ac2deb..1a35164 100644 --- a/rust/tests/dispatch.rs +++ b/rust/tests/dispatch.rs @@ -205,3 +205,28 @@ fn help_shows_impl_variants() { assert!(out.contains("state (python, rust)"), "{out}"); assert!(out.contains("DETERMA__IMPL")); } + +#[test] +fn reserved_family_commands_are_hidden_from_discovery() { + let sp = StubPath::new(); + for name in [ + "determa-alpha", + "determa-auth", + "determa-config", + "determa-config-rust", + "determa-context", + ] { + sp.add(name, "unused"); + } + let isolated = format!("{}", sp.dir.path().to_string_lossy()); + let (rc, out, _) = run(&["list"], &isolated, &[]); + assert_eq!(rc, 0); + assert_eq!(out, "alpha\n"); + + let (rc, out, _) = run(&["--help"], &isolated, &[]); + assert_eq!(rc, 0); + assert!(out.contains(" alpha")); + assert!(!out.contains(" auth")); + assert!(!out.contains(" config")); + assert!(!out.contains(" context")); +} diff --git a/rust/tests/family_connection_context_v1_api.rs b/rust/tests/family_connection_context_v1_api.rs new file mode 100644 index 0000000..6e85365 --- /dev/null +++ b/rust/tests/family_connection_context_v1_api.rs @@ -0,0 +1,58 @@ +use determa::family_connection_context_v1 as family; +use serde_json::{json, Value}; + +#[test] +fn decoded_configuration_is_opaque_and_isolated_from_mutation() { + let mut source = json!({ + "version": 1, + "connections": {"cloud": {"endpoint": "https://example.com"}}, + "contexts": {}, + }); + let configuration = family::validate_configuration(&source).unwrap(); + source["connections"]["cloud"]["endpoint"] = + Value::String("https://changed.example".to_string()); + let mut exported = configuration.to_value(); + exported["connections"] = json!({}); + + assert_eq!( + family::resolve_connection( + &configuration, + &json!({"resource": "state", "explicit_connection": "cloud"}), + ) + .unwrap(), + "cloud" + ); +} + +#[test] +fn malformed_requests_return_family_errors() { + let configuration = family::validate_configuration(&json!({ + "version": 1, + "connections": {}, + "contexts": {}, + })) + .unwrap(); + for request in [ + Value::Null, + Value::Array(Vec::new()), + Value::String("state".to_string()), + Value::Number(1.into()), + ] { + assert_eq!( + family::resolve_connection(&configuration, &request) + .unwrap_err() + .code, + "invalid_type" + ); + } +} + +#[test] +fn malformed_decoded_configuration_returns_family_error() { + assert_eq!( + family::validate_configuration(&Value::Null) + .unwrap_err() + .code, + "invalid_type" + ); +} diff --git a/scripts/validate-family-connection-v1-vectors.py b/scripts/validate-family-connection-v1-vectors.py index 533675c..69c69fc 100755 --- a/scripts/validate-family-connection-v1-vectors.py +++ b/scripts/validate-family-connection-v1-vectors.py @@ -108,9 +108,23 @@ def parse_configuration_source(source: Any) -> dict[str, Any]: raise except (json.JSONDecodeError, UnicodeError): raise VectorError("invalid_source") from None + require_unicode_scalars(value) return validate_configuration(value) +def require_unicode_scalars(value: Any) -> None: + if isinstance(value, str): + if any(0xD800 <= ord(character) <= 0xDFFF for character in value): + raise VectorError("invalid_source") + elif isinstance(value, list): + for item in value: + require_unicode_scalars(item) + elif isinstance(value, dict): + for key, item in value.items(): + require_unicode_scalars(key) + require_unicode_scalars(item) + + def require_closed_object( value: Any, required: set[str], optional: set[str] ) -> dict[str, Any]: @@ -550,7 +564,12 @@ def first_route(routes: dict[str, str], resource: str) -> Any: def resolve_connection( configuration: dict[str, Any], request: dict[str, Any] ) -> str: - resource = request.get("resource") + request = require_closed_object( + request, + {"resource"}, + {"explicit_connection", "environment", "selected_context"}, + ) + resource = request["resource"] validate_resource(resource) connections = configuration["connections"] From 4ce0d1dc39705fb0925bd1afcb3d99dd00ceb0b5 Mon Sep 17 00:00:00 2001 From: Christian-Manuel Butzke Date: Mon, 17 Aug 2026 03:29:06 +0900 Subject: [PATCH 5/5] Reject multiple embedded IPv4 productions --- AGENTS.md | 4 ++-- conformance/family-connection-v1/endpoints.json | 5 +++++ docs/family-connection-context-v1.md | 5 +++-- node/lib/family-connection-context-v1.js | 7 ++++++- node/test/family-connection-context-v1.test.js | 14 ++++++++++++++ 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index af4f190..a983f08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,9 +50,9 @@ python3 scripts/validate-family-connection-v1-vectors.py cd python && pip install -e '.[dev]' && ruff check . && pytest -q # rust python3 scripts/check-rust-msrv.py rust -cd rust && cargo build --release --locked && cargo clippy --release --all-targets --all-features --locked -- -D warnings && cargo test --locked && cargo test --locked --features repository-fixtures +cd rust && cargo build --release --locked && cargo clippy --release --all-targets --all-features --locked -- -D warnings && cargo test --locked && cargo run --locked --example family-connection-context-v1-vectors --features repository-fixtures # rust MSRV -cd rust && cargo +1.81.0 build --release --locked && cargo +1.81.0 test --locked && cargo +1.81.0 test --locked --features repository-fixtures +cd rust && cargo +1.81.0 build --release --locked && cargo +1.81.0 test --locked && cargo +1.81.0 run --locked --example family-connection-context-v1-vectors --features repository-fixtures # node cd node && npm ci && npm test ``` diff --git a/conformance/family-connection-v1/endpoints.json b/conformance/family-connection-v1/endpoints.json index 4e6fb48..ea5d0d0 100644 --- a/conformance/family-connection-v1/endpoints.json +++ b/conformance/family-connection-v1/endpoints.json @@ -272,6 +272,11 @@ "input": "https://[192.0.2.1::1]/", "expect": {"error": "invalid_endpoint_host"} }, + { + "id": "endpoint-multiple-ipv4-productions-in-ipv6", + "input": "https://[192.0.2.1::5.6.7.8]/", + "expect": {"error": "invalid_endpoint_host"} + }, { "id": "endpoint-ipv6-zone", "input": "http://[fe80::1%25en0]/", diff --git a/docs/family-connection-context-v1.md b/docs/family-connection-context-v1.md index fae9a7e..f7e8f91 100644 --- a/docs/family-connection-context-v1.md +++ b/docs/family-connection-context-v1.md @@ -198,8 +198,9 @@ library's platform-dependent URL normalization is not normative. accepted by some URL libraries. - A bracketed IPv6 literal MUST parse under RFC 4291 section 2.2 and MUST be emitted in brackets using RFC 5952 sections 4.1 through 4.3. Always emit - all 128 bits in hexadecimal form. A dotted-decimal IPv4 suffix is accepted - only when it supplies the final 32 bits of the IPv6 address; for example, + all 128 bits in hexadecimal form. At most one dotted-decimal IPv4 + production is accepted, and only when it supplies the single final 32-bit + component of the IPv6 address; for example, `::ffff:192.0.2.1` is emitted as `::ffff:c000:201`, never with dotted decimal, while `192.0.2.1::1` is invalid. IPvFuture literals are invalid. Zone identifiers, including RFC 6874 `%25zone` syntax, are invalid. diff --git a/node/lib/family-connection-context-v1.js b/node/lib/family-connection-context-v1.js index a983f04..b765c5e 100644 --- a/node/lib/family-connection-context-v1.js +++ b/node/lib/family-connection-context-v1.js @@ -431,7 +431,12 @@ function parseIpv6Part(part) { function parseIpv6(rawHost) { if (!rawHost || rawHost.includes("%")) fail("invalid_endpoint_host"); if ((rawHost.match(/::/g) || []).length > 1) fail("invalid_endpoint_host"); - if (rawHost.includes(".") && !/(?:^|:)(?:0|[1-9][0-9]*)(?:\.(?:0|[1-9][0-9]*)){3}$/.test(rawHost)) { + const colonParts = rawHost.split(":"); + const dottedParts = colonParts.filter(part => part.includes(".")); + if ( + dottedParts.length > 1 || + (dottedParts.length === 1 && dottedParts[0] !== colonParts[colonParts.length - 1]) + ) { fail("invalid_endpoint_host"); } diff --git a/node/test/family-connection-context-v1.test.js b/node/test/family-connection-context-v1.test.js index 4132c7e..6190fd0 100644 --- a/node/test/family-connection-context-v1.test.js +++ b/node/test/family-connection-context-v1.test.js @@ -35,6 +35,20 @@ for (const case_ of loadFixture("endpoints.json").cases) { assertCase(case_, () => family.canonicalizeEndpoint(case_.input)); } +for (const endpoint of [ + "https://[192.0.2.1::5.6.7.8]/", + "https://[::192.0.2.1:5.6.7.8]/", + "https://[1:2:3:4:192.0.2.1:5.6.7.8]/", +]) { + assert.throws( + () => family.canonicalizeEndpoint(endpoint), + error => + error instanceof family.FamilyConnectionError && + error.code === "invalid_endpoint_host", + endpoint + ); +} + const environmentFixture = loadFixture("environment.json"); const environmentResults = new Map(); for (const case_ of environmentFixture.cases) {