From 76b022679a391d54a3c877a8dbc3013c53c68967 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Wed, 7 Oct 2026 13:50:15 -0400 Subject: [PATCH] feat: plan native routing and worktree domain selection --- .github/workflows/ci.yml | 3 + docs/reference/cli.md | 6 +- docs/reference/native-config-compiler.md | 101 +- packages/config-compiler/README.md | 70 +- .../generated/hack.local.schema.json | 32 + .../generated/hack.project.schema.json | 115 ++ .../generated/native-config.ts | 26 +- packages/config-compiler/src/environment.rs | 36 +- packages/config-compiler/src/lib.rs | 24 +- packages/config-compiler/src/local.rs | 130 +- packages/config-compiler/src/model.rs | 23 + packages/config-compiler/src/routing.rs | 660 +++++++++ packages/config-compiler/src/shape.rs | 16 +- packages/config-compiler/src/validate.rs | 5 + packages/config-compiler/tests/compiler.rs | 2 +- .../tests/fixtures/local-schema-corpus.json | 128 ++ .../tests/fixtures/schema-corpus.json | 707 ++++++++- packages/config-compiler/tests/protocol.rs | 2 +- packages/config-compiler/tests/routing.rs | 362 +++++ scripts/check-native-routing-plan-cli.ts | 499 +++++++ src/commands/config-plan.ts | 16 +- src/commands/config-validate.ts | 22 +- src/lib/native-config-compiler.ts | 156 +- src/lib/native-project-validation.ts | 67 +- src/lib/native-routing-inputs.ts | 164 +++ src/lib/native-routing-plan-protocol.ts | 546 +++++++ src/lib/worktree-local-config.ts | 122 +- tests/helpers/scoped-module-mock.ts | 42 +- ...ve-project-inputs-acquisition-race.test.ts | 7 +- tests/native-routing-input-race.test.ts | 125 ++ tests/native-routing-inputs.test.ts | 267 ++++ tests/native-routing-plan-transport.test.ts | 1279 +++++++++++++++++ tests/native-routing-project.test.ts | 271 ++++ 33 files changed, 5961 insertions(+), 70 deletions(-) create mode 100644 packages/config-compiler/src/routing.rs create mode 100644 packages/config-compiler/tests/routing.rs create mode 100644 scripts/check-native-routing-plan-cli.ts create mode 100644 src/lib/native-routing-inputs.ts create mode 100644 src/lib/native-routing-plan-protocol.ts create mode 100644 tests/native-routing-input-race.test.ts create mode 100644 tests/native-routing-inputs.test.ts create mode 100644 tests/native-routing-plan-transport.test.ts create mode 100644 tests/native-routing-project.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ed2c384b..5cc9efce5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: bunx ultracite check scripts/check-native-host-plan-cli.ts tests/native-host-plan-transport.test.ts src/lib/native-host-plan-protocol.ts bun test tests/native-host-plan-transport.test.ts bun scripts/check-native-host-plan-cli.ts + bunx ultracite check scripts/check-native-routing-plan-cli.ts src/lib/native-routing-plan-protocol.ts src/lib/native-routing-inputs.ts tests/native-routing-plan-transport.test.ts tests/native-routing-inputs.test.ts tests/native-routing-input-race.test.ts tests/native-routing-project.test.ts + bun test tests/native-routing-plan-transport.test.ts tests/native-routing-inputs.test.ts tests/native-routing-input-race.test.ts tests/native-routing-project.test.ts + bun scripts/check-native-routing-plan-cli.ts state-models: name: Runtime state models diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 968f4ae12..22ef023f2 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -580,7 +580,7 @@ Validate native configuration and selected local overlays without starting workl hack config validate [options] ``` -Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads. +Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads. ### Options @@ -588,6 +588,7 @@ Uses the matching bundled Rust compiler. Without --file, discovers a native proj | --- | --- | | `--file ` | Validate only this native project JSON file, without discovery or local overrides | | `--profile ` | Comma-separated declared native profiles | +| `--domain ` | Select the generated native routing domain suffix | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--env ` | Apply an optional env overlay by name (use 'base' to bypass overlays) | | `--json` | Output JSON (machine-readable) | @@ -605,13 +606,14 @@ Inspect native environment binding completeness without decrypting or starting w hack config plan [options] ``` -Resolves the native project and permitted local env selection, then inspects managed-env names, winning scopes and secret flags. Managed YAML is parsed by its existing owner; keys and values are never decrypted or returned. Required unresolved refs return a nonzero exit. This experimental report does not establish runtime admission or enable native execution; use config validate for validation without managed-document reads. +Resolves the native project, permitted local env selection and routing preview, then inspects managed-env names, winning scopes and secret flags. Managed YAML is parsed by its existing owner; keys and values are never decrypted or returned. Required unresolved refs return a nonzero exit. This experimental report does not establish runtime admission or enable native execution; use config validate for validation without managed-document reads. ### Options | Option | Description | | --- | --- | | `--profile ` | Comma-separated declared native profiles | +| `--domain ` | Select the generated native routing domain suffix | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--env ` | Apply an optional env overlay by name (use 'base' to bypass overlays) | | `--json` | Output JSON (machine-readable) | diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index 7bf147d6e..9b7bc2a38 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -82,8 +82,8 @@ versions refuse; omitted fields retain their documented defaults. command selection. - Project `worktree.auto_branch` and `worktree.inherit_local` are strict booleans, both defaulting to true. They appear in the normalized plan. This validation - command uses inheritance policy; it does not create branch instances or execute - `auto_branch` behavior. + command uses inheritance policy and previews the verified linked-worktree branch + namespace for routing. It does not create branch instances. - Dependencies are `{service:"db",condition:"started"|"ready"}` or `{job:"init",condition:"completed"}`. References must match the declared kind. Ready dependencies require explicit readiness. Services and jobs share one name @@ -107,12 +107,78 @@ backslashes and drive syntax. Lexical `.` and repeated separators normalize; no filesystem or symlink resolution occurs. Working directories and mount targets must be absolute POSIX container paths without `..`. -Routes, container shutdown/restart policies, endpoint references, +Container shutdown/restart policies, endpoint references, network/security/resources, cache protocols, backend options, arbitrary extensions, -and local settings other than environment selection are not yet implemented. They refuse rather than being +and other local settings are not yet implemented. They refuse rather than being silently dropped. This foundation does not replace the full native contract or qualify a migrated advanced project. +## Routing and domain previews + +Optional `routes` declares project origins, aliases and named HTTP routes: + +```json +{ + "schema_version": 1, + "name": "example", + "services": { "web": { "image": "example/web:1" } }, + "routes": { + "domain": "hack.local", + "aliases": { "oauth": { "domain": "hack.gy" } }, + "oauth_alias": "oauth", + "http": { + "web": { "service": "web", "port": 3000, "hostname": "project" } + } + }, + "open": { "prefer": "auto" } +} +``` + +`routes.domain` is a canonical lowercase DNS suffix. The effective suffix is +explicit `--domain`, checkout local, inherited verified primary local, project, +global `default_domain`, then `hack.local`, in that order. A global domain is +read as one scalar from a bounded regular config file; private global fields are +not sent to the compiler. Invalid or redirected policy files refuse. + +Generated origins are `https://[branch.]NAME.SUFFIX`. Branch names are derived +from a verified linked Git worktree and normalized with the existing branch +namespace rules. Primary checkouts, non-Git projects, CI, slim mode and +`worktree.auto_branch:false` omit that namespace. Detached linked worktrees refuse +when a branch is required. `inherit_local:false` disables primary settings without +disabling branch isolation. + +`routes.origin` pins an explicit HTTP(S) project origin. An alias selects exactly +one `domain` or `origin`; generated domain aliases follow the same branch namespace, +while explicit origins remain pinned. Origins normalize scheme/host case and +default ports. Credentials, paths (including `/`), queries, fragments, wildcards, +ambiguous numeric hosts and invalid ports refuse. Canonical loopback IPv4, bracketed +IPv6 and `localhost` are allowed. Generated names must be valid DNS labels. + +Each `routes.http` entry requires a declared **service**, a port in 1–65,535 and +`hostname`: `project` uses the base origin; another canonical relative DNS name +prefixes each project/alias hostname. IP origins cannot be prefixed. Upstream +`protocol` defaults to `http`; it is separate from the browser origin's scheme. +Unknown services, job targets, duplicate origins and expanded route/alias collisions +refuse, including in inactive declarations. Inactive routes stay in the portable +plan but are omitted from the selected routing report. Expansion is bounded before +allocation and charged to the shared planning output budget. + +`open.prefer` accepts `auto` (default), `alias` or `dev`. `auto` selects the +explicit `routes.oauth_alias` when present, otherwise the project origin; `alias` +requires that selection; `dev` selects the project origin. Alias names have no +implicit OAuth meaning. Local `open.prefer` overrides inherited/project settings. +`hack.local` remains the development default; an OAuth provider that requires a +public suffix can use an explicitly selected `hack.gy` or custom-domain alias. +Provider registration and browser acceptance are separate checks. + +Project-aware `config validate` and `config plan` return `routing_resolution` with +the selected domain and provenance, project/alias origins, branch namespace, open +choice and selected routes. The CLI cross-checks the report against authored +declarations and request context. Routing changes affect the local resolution hash; +local policy never rewrites the authored semantic hash. This is offline planning: +it does not configure DNS, trust certificates, run hooks, bind ports or admit a +runtime. Endpoint bindings and execution remain later integration steps. + ## Host declarations The optional `host` namespace declares ordered lifecycle hooks and named processes: @@ -189,6 +255,7 @@ The CLI supports project-aware and explicit-document validation: ```sh hack config validate --json hack config validate --path /path/to/native-project --env base --json +hack config validate --domain hack.gy --json hack config validate --file .hack/hack.project.json hack config validate --file .hack/hack.project.json --profile dev,test --json ``` @@ -200,19 +267,22 @@ explicitly selects base; another canonical name selects that overlay. This selec a name only: overlay existence, managed metadata, keys and required references are not inspected, and no env values are read. Runtime/adoption commands remain fenced. -`--file` validates only that document, ignores all local files and performs no -project discovery. It cannot be combined with `--path` or `--env`. +`--file` validates only that document, ignores local/global policy files and performs +no project discovery or origin resolution. It cannot be combined with `--path`, +`--env` or `--domain`. `--json` returns the authored normalized plan, including authored public literals and commands. Those values are intentionally visible; diagnostic redaction does not turn the plan into a secret-safe storage format. - `hack-config-compiler --protocol` emits - `{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1}`. + `{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1}`. Project-aware validation requires local resolution capabilities; explicit metadata planning additionally requires `env_plan_version:1`. Older matching compilers can still serve their supported validation modes. Host planning requires `host_env_plan_version:1` before acquiring host metadata; hostless env-plan-v1 compilers remain supported. + Typed routing and policy resolution require `routing_plan_version:1`; unchanged + non-routing calls remain compatible with older matching compilers. - `hack-config-compiler compile [--profile NAME]...` reads one UTF-8 JSON document from stdin through EOF. Input is limited to 1 MiB and 64 nested containers. These are parser safety bounds, not container resource or workload-count limits. @@ -228,11 +298,17 @@ diagnostic redaction does not turn the plan into a secret-safe storage format. from the authored identity and includes explicit targets from inactive workloads. - Invalid invocation exits 2 with fixed usage on stderr and no JSON on stdout. - `hack-config-compiler resolve [--profile NAME]...` reads a versioned request: - `{request_version:1,project:"original JSON text",primary_local?:"original JSON text",checkout_local?:"original JSON text",explicit_overlay?:null|string}`. + `{request_version:1,project:"original JSON text",primary_local?:"original JSON text",checkout_local?:"original JSON text",explicit_overlay?:null|string,global_domain?:string,explicit_domain?:string,branch?:string}`. JSON texts retain duplicate keys until Rust checks each document. Each document is limited to 1 MiB, their combined text to 3 MiB, and the encoded request to 20 MiB to allow JSON escaping. Diagnostics add `document` identifying `project`, `primary_local`, `checkout_local` or `request`. + A routing-v1 compiler also accepts `routing_probe:true` on `resolve` only. + This validates authored/local policy and returns `routing_inputs_required:true` + when routing context is needed, without deriving provisional origins. The CLI + then acquires verified global/branch inputs and sends an ordinary resolve. + The probe is not part of the resolution hash and is never a final plan. Old + non-routing compilers receive no probe flag. - `hack-config-compiler generate DIR` writes deterministic `hack.project.schema.json`, `hack.local.schema.json` (2020-12) and `native-config.ts` projections. @@ -243,6 +319,7 @@ diagnostic redaction does not turn the plan into a secret-safe storage format. hack config plan --json hack config plan --path /path/to/native-project --env qa --profile dev --json hack config plan --env base +hack config plan --domain hack.gy --json ``` This project-aware command uses the same validated local selection as `config @@ -302,8 +379,9 @@ The optional `.hack/hack.local.json` is a separate versioned document: {"schema_version":1,"environment":{"default_overlay":null}} ``` -Only `environment.default_overlay` is supported in this slice. Omission inherits; -null selects base; a canonical name selects that overlay. Other fields, workload +Local settings permit `environment.default_overlay`, `routes.domain` and +`open.prefer`. Omission inherits; overlay null selects base, while domain/open null +refuses. A canonical overlay name selects that overlay. Other fields, workload definitions, unversioned documents, duplicate keys and unknown versions refuse. The effective selection is project default/base, then verified primary local, current checkout local, then explicit `--env`. Each later present value wins. @@ -319,7 +397,8 @@ local settings; primary inheritance requires the project root to be the Git root Success adds `local_resolution` with selected `overlay` (null means base), `origin`, worktree policy and `resolution_hash`. Local settings do not rewrite the authored plan or its `semantic_hash`. The separate hash binds normalized supplied local -documents and explicit selection, including missing versus null. Neither hash +documents, supplied routing context and explicit selection, including missing +versus null. Neither hash proves an atomic multi-file snapshot or provides an admission/freshness fence. Future apply must recheck private input generations. Adding defaulted worktree policy changes hashes relative to the earlier experimental compiler; do not reuse diff --git a/packages/config-compiler/README.md b/packages/config-compiler/README.md index 6ec484893..683ee6809 100644 --- a/packages/config-compiler/README.md +++ b/packages/config-compiler/README.md @@ -31,8 +31,8 @@ are rejected. Each document is limited to 1 MiB, their combined decoded size to 3 MiB, and the encoded request to 20 MiB. All JSON parsing has a depth budget of 64. These are parser resource bounds, not runtime workload limits. -A local document permits only `schema_version: 1` and optional `environment`, -which permits only `default_overlay`. Omission inherits, `null` selects base, +A local document permits `schema_version: 1` and optional `environment`, +`routes`, and `open`. Environment permits only `default_overlay`. Omission inherits, `null` selects base, and a string selects a canonical overlay matching `[a-z0-9]+(?:-[a-z0-9]+)*`. No normalization silently changes names. @@ -204,3 +204,69 @@ bindings. This may refuse near-limit reports before their final encoding reaches project location. The bound prevents input-to-output amplification and is shared across workload and host reporting; it is not a process-count or runtime-resource limit. No metadata-derived hash or managed value is exposed by the budget. + + +## Typed routing and domain planning + +The protocol advertises `routing_plan_version: 1`. Optional project `routes` +contains `domain`, `origin`, named `aliases`, `oauth_alias`, and an `http` map +(default empty). Each HTTP entry requires a declared service, a port from 1–65535, +and `hostname`; `protocol` defaults to `http` and also accepts `https`. Hostname +`project` uses the project origin; other canonical relative DNS labels prefix its +hostname. Jobs cannot receive HTTP routes. All declarations, including inactive +services, are validated before profile filtering. The portable plan retains every +route; the resolved routing report contains only selected services. + +An alias has exactly one of `{domain: "example.test"}` or +`{origin: "http://localhost:3000"}`. `oauth_alias` explicitly selects a declared +alias. Optional project `open.prefer` is `auto` (default), `alias`, or `dev`. +`auto` uses the explicitly selected OAuth alias when present, otherwise the project +origin. `alias` without that selection refuses during resolution; `dev` uses the +project origin. This describes navigation intent, not OAuth-provider acceptance. + +Local `routes` permits only optional `domain`; local `open` permits only optional +`prefer`. Null is invalid and empty objects inherit. Resolve and plan requests add +optional `global_domain`, `explicit_domain`, and `branch`. Domain precedence is +explicit request, checkout local, inherited primary local, project, global, then +`hack.local`. Open preference precedence is checkout local, inherited primary local, +project, then `auto`. Every supplied document is validated, including shadowed or +opted-out primary settings. Generated project and domain-alias origins are +`https://[branch.]project.domain`; explicit origins are never rewritten. Branch +must be one canonical DNS label. Project names need that DNS grammar only when +used in generated origins. + +Domain suffixes use lowercase ASCII DNS labels and require two or more labels, +except the supported `hack` suffix. Decimal or hexadecimal numeric final labels, +underscores, wildcard labels and trailing dots refuse. Relative route hostnames +have their own DNS-label grammar. Explicit origins accept only HTTP or HTTPS, +normalize hostname case and default ports, and permit localhost, strict IPv4 and +bracketed IPv6. Credentials, paths (including `/`), query, fragment, whitespace, +control characters and legacy numeric-IP forms refuse. IPv6 is normalized to +compressed hexadecimal form. A relative route cannot prefix an IP origin. +Duplicate normalized project/alias origins and route expansions refuse, even for +inactive services or projects with no HTTP entries. + +Routing becomes active when authored or effective local routing/open settings, +or any domain/branch request input, is present. Successful resolve and plan then +add `routing_resolution`: effective domain and its source, project origin, alias +origins, selected OAuth alias or null, open preference and its source, open origin, +optional branch, and selected route origins with alias expansions. Supplied domain +and branch choices enter the separate resolution hash, not the authored semantic +hash. Existing calls without routing inputs preserve their previous output and +hashes. Empty authored routing/open objects explicitly enable routing defaults. + +Resolve alone supports `routing_probe: true` for an acquisition pass. It validates +original documents and static routing references but defers origin expansion and +context-dependent collisions until the caller acquires the global domain and +verified branch. Active routing returns `routing_inputs_required: true` instead +of `routing_resolution`; inactive routing returns neither field. The probe flag +never enters a hash. Plan refuses the probe field, including `false`; a probe is +not a completed routing plan. Consumers must perform the final normal resolution. + +Routing expansion counts output space before allocating repeated route/alias +entries, using the same conservative 8 MiB response safety budget as environment +planning. Environment planning reserves the routing report before copying symbolic +bindings. Oversized expansion returns fixed redacted `plan_too_large`; this is an +input-amplification bound, not a runtime route or workload capacity limit. This +package does not register DNS, acquire certificates, bind ports, configure proxies, +open browsers or claim that an origin is reachable. diff --git a/packages/config-compiler/generated/hack.local.schema.json b/packages/config-compiler/generated/hack.local.schema.json index 921f89fa6..358a8256a 100644 --- a/packages/config-compiler/generated/hack.local.schema.json +++ b/packages/config-compiler/generated/hack.local.schema.json @@ -7,6 +7,12 @@ "$ref": "#/$defs/LocalEnvironment", "default": {} }, + "open": { + "$ref": "#/$defs/LocalOpen" + }, + "routes": { + "$ref": "#/$defs/LocalRoutes" + }, "schema_version": { "type": "integer", "format": "uint32", @@ -31,6 +37,32 @@ } }, "additionalProperties": false + }, + "LocalOpen": { + "type": "object", + "properties": { + "prefer": { + "$ref": "#/$defs/OpenPreference" + } + }, + "additionalProperties": false + }, + "LocalRoutes": { + "type": "object", + "properties": { + "domain": { + "type": "string" + } + }, + "additionalProperties": false + }, + "OpenPreference": { + "type": "string", + "enum": [ + "auto", + "alias", + "dev" + ] } } } diff --git a/packages/config-compiler/generated/hack.project.schema.json b/packages/config-compiler/generated/hack.project.schema.json index 6273cf241..a4446b5bf 100644 --- a/packages/config-compiler/generated/hack.project.schema.json +++ b/packages/config-compiler/generated/hack.project.schema.json @@ -354,6 +354,40 @@ ], "type": "string" }, + "HttpProtocol": { + "enum": [ + "http", + "https" + ], + "type": "string" + }, + "HttpRoute": { + "additionalProperties": false, + "properties": { + "hostname": { + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "protocol": { + "$ref": "#/$defs/HttpProtocol", + "default": "http" + }, + "service": { + "type": "string" + } + }, + "required": [ + "service", + "port", + "hostname" + ], + "type": "object" + }, "JobCondition": { "enum": [ "completed" @@ -404,6 +438,24 @@ } ] }, + "OpenConfig": { + "additionalProperties": false, + "properties": { + "prefer": { + "$ref": "#/$defs/OpenPreference", + "default": "auto" + } + }, + "type": "object" + }, + "OpenPreference": { + "enum": [ + "auto", + "alias", + "dev" + ], + "type": "string" + }, "Readiness": { "oneOf": [ { @@ -514,6 +566,63 @@ } ] }, + "RouteAlias": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "domain": { + "type": "string" + } + }, + "required": [ + "domain" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "origin": { + "type": "string" + } + }, + "required": [ + "origin" + ], + "type": "object" + } + ] + }, + "Routes": { + "additionalProperties": false, + "properties": { + "aliases": { + "additionalProperties": { + "$ref": "#/$defs/RouteAlias" + }, + "default": {}, + "type": "object" + }, + "domain": { + "type": "string" + }, + "http": { + "additionalProperties": { + "$ref": "#/$defs/HttpRoute" + }, + "default": {}, + "type": "object" + }, + "oauth_alias": { + "type": "string" + }, + "origin": { + "type": "string" + } + }, + "type": "object" + }, "ServiceCondition": { "enum": [ "started", @@ -680,6 +789,9 @@ "name": { "type": "string" }, + "open": { + "$ref": "#/$defs/OpenConfig" + }, "profiles": { "default": [], "items": { @@ -687,6 +799,9 @@ }, "type": "array" }, + "routes": { + "$ref": "#/$defs/Routes" + }, "schema_version": { "format": "uint32", "maximum": 1, diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts index 71fd1b3d0..b48216913 100644 --- a/packages/config-compiler/generated/native-config.ts +++ b/packages/config-compiler/generated/native-config.ts @@ -3,10 +3,10 @@ export type ManagedBindingMetadata = { scope: string, secret: boolean, }; export type EnvMetadata = { metadata_version: 1, overlay: string | null, overlay_exists: boolean, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, inactive_scopes: Array, host?: HostMetadata, }; export type HostMetadata = { default?: { [key in string]: ManagedBindingMetadata }, workloads: { [key in string]: { [key in string]: ManagedBindingMetadata } }, }; export type HostEnvironmentPlan = { env_target: HostEnvTarget, bindings: { [key in string]: EnvironmentBinding }, }; -export type EnvPlanRequest = { env_metadata: EnvMetadata, request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, }; +export type EnvPlanRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, global_domain?: string, explicit_domain?: string, branch?: string, env_metadata: EnvMetadata, }; export type EnvironmentBinding = { "kind": "managed", key: string, scope: string, secret: boolean, } | { "kind": "literal", value: string, } | { "kind": "default", value: string, }; export type EnvironmentPlan = { plan_version: 1, overlay: string | null, overlay_exists: boolean, complete: boolean, workloads: { [key in string]: { [key in string]: EnvironmentBinding } }, host?: { [key in string]: HostEnvironmentPlan }, warnings: Array, diagnostics: Array, }; -export type PlanResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, environment_plan: EnvironmentPlan, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type PlanResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, routing_resolution?: RoutingResolution, environment_plan: EnvironmentPlan, } | { transport_version: 1, ok: false, diagnostics: Array, }; export type HostConfig = { up?: HostHooks, down?: HostHooks, processes?: { [key in string]: HostProcess }, }; export type HostHooks = { before?: Array, after?: Array, }; export type HostHook = { name: string, command: Command, cwd?: string, environment?: { [key in string]: EnvironmentValue }, env_target?: HostEnvTarget, }; @@ -17,6 +17,18 @@ export type HostExit = "stop_on_down"; export type HostSingleton = { ports: Array, on_conflict?: HostConflict, }; export type HostConflict = "fail" | "adopt"; export type HostEnvTargets = { include_default: boolean, workloads: Array, }; +export type Routes = { domain?: string, origin?: string, aliases?: { [key in string]: RouteAlias }, oauth_alias?: string, http?: { [key in string]: HttpRoute }, }; +export type RouteAlias = { domain: string, } | { origin: string, }; +export type HttpRoute = { service: string, port: number, protocol?: HttpProtocol, hostname: string, }; +export type HttpProtocol = "http" | "https"; +export type OpenConfig = { prefer?: OpenPreference, }; +export type OpenPreference = "auto" | "alias" | "dev"; +export type LocalRoutes = { domain?: string, }; +export type LocalOpen = { prefer?: OpenPreference, }; +export type DomainOrigin = "default" | "global" | "project" | "primary_local" | "checkout_local" | "explicit"; +export type OpenPreferenceOrigin = "default" | "project" | "primary_local" | "checkout_local"; +export type ResolvedHttpRoute = { service: string, port: number, protocol: HttpProtocol, origin: string, aliases: { [key in string]: string }, }; +export type RoutingResolution = { domain: string, domain_origin: DomainOrigin, project_origin: string, aliases: { [key in string]: string }, oauth_alias: string | null, open_preference: OpenPreference, open_preference_origin: OpenPreferenceOrigin, open_origin: string, branch?: string, routes: { [key in string]: ResolvedHttpRoute }, }; export type WorktreePolicy = { auto_branch?: boolean, inherit_local?: boolean, }; export type SourceMode = "host-mounted"; export type Source = { root?: string, mode?: SourceMode, }; @@ -35,16 +47,16 @@ export type JobCondition = "completed"; export type Dependency = { service: string, condition: ServiceCondition, } | { job: string, condition: JobCondition, }; export type Readiness = { "kind": "exec", command: Command, interval: string, timeout: string, retries: number, } | { "kind": "http", port: number, path: string, interval: string, timeout: string, retries: number, } | { "kind": "tcp", port: number, interval: string, timeout: string, retries: number, }; export type Workload = { image?: string, build?: Build, command?: Command, working_directory?: string, mounts?: Array, environment?: { [key in string]: EnvironmentValue }, depends_on?: Array, profiles?: Array, readiness?: Readiness, }; -export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, worktree?: WorktreePolicy, host?: HostConfig, }; -export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, host?: HostConfig, selected_profiles: Array, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, }; +export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, worktree?: WorktreePolicy, host?: HostConfig, routes?: Routes, open?: OpenConfig, }; +export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, host?: HostConfig, routes?: Routes, open?: OpenConfig, selected_profiles: Array, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, }; export type Diagnostic = { code: string, message: string, pointer: string, line: number, column: number, }; export type WorkloadKind = "service" | "job"; export type CompileResult = { transport_version: 1, ok: true, plan: Plan, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, semantic_hash: string, } | { transport_version: 1, ok: false, diagnostics: Array, }; export type LocalEnvironment = { default_overlay?: string | null, }; -export type LocalConfig = { schema_version: 1, environment?: LocalEnvironment, }; -export type ResolveRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, }; +export type LocalConfig = { schema_version: 1, environment?: LocalEnvironment, routes?: LocalRoutes, open?: LocalOpen, }; +export type ResolveRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, global_domain?: string, explicit_domain?: string, branch?: string, routing_probe?: boolean, }; export type DocumentRole = "project" | "primary_local" | "checkout_local" | "request"; export type ResolveDiagnostic = { document: DocumentRole, code: string, message: string, pointer: string, line: number, column: number, }; export type OverlayOrigin = "project" | "primary_local" | "checkout_local" | "explicit"; export type LocalResolution = { overlay: string | null, origin: OverlayOrigin, auto_branch: boolean, inherit_local: boolean, resolution_hash: string, }; -export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, host_env_targets?: HostEnvTargets, local_resolution: LocalResolution, routing_resolution?: RoutingResolution, routing_inputs_required?: true, } | { transport_version: 1, ok: false, diagnostics: Array, }; diff --git a/packages/config-compiler/src/environment.rs b/packages/config-compiler/src/environment.rs index 21dffe06e..7cb0bea9c 100644 --- a/packages/config-compiler/src/environment.rs +++ b/packages/config-compiler/src/environment.rs @@ -57,8 +57,27 @@ pub struct HostEnvironmentPlan { /// Projection only; parsing retains strict resolve fields and separately validates metadata. #[derive(Debug, Serialize, JsonSchema, TS)] pub struct EnvPlanRequest { - #[serde(flatten)] - pub request: local::ResolveRequest, + #[ts(type = "1")] + pub request_version: u32, + pub project: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "string")] + pub primary_local: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "string")] + pub checkout_local: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable,as="Option")] + pub explicit_overlay: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "string")] + pub global_domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "string")] + pub explicit_domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "string")] + pub branch: Option, pub env_metadata: EnvMetadata, } #[derive(Debug, Serialize, JsonSchema, TS)] @@ -105,6 +124,9 @@ pub enum PlanResult { #[ts(optional, type = "HostEnvTargets")] host_env_targets: Option, local_resolution: local::LocalResolution, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "RoutingResolution")] + routing_resolution: Option>, environment_plan: Box, }, Failure { @@ -143,6 +165,7 @@ pub fn plan(bytes: &[u8], profiles: &[String]) -> PlanResult { semantic_hash: resolved.compiled.semantic_hash, declared_workloads: resolved.compiled.declared_workloads, local_resolution: resolved.local_resolution, + routing_resolution: resolved.routing_resolution.map(Box::new), environment_plan: Box::new(environment_plan), }, Err(error) => PlanResult::failure(error), @@ -296,6 +319,12 @@ fn plan_inner( ) -> Result<(local::Resolved, EnvironmentPlan), local::ResolveDiagnostic> { let mut document = json::parse_with_limit(bytes, local::MAX_REQUEST_BYTES) .map_err(|d| local::with_role(local::DocumentRole::Request, d))?; + if document.value.get("routing_probe").is_some() { + return Err(local::with_role( + local::DocumentRole::Request, + diagnostic_at(&document.positions, "unknown_field", "/routing_probe"), + )); + } let metadata = read_metadata(&mut document)?; let metadata_location = diagnostic_at(&document.positions, "invalid_metadata", "/env_metadata"); let resolved = local::resolve_document(document, profiles)?; @@ -517,7 +546,7 @@ impl std::io::Write for CountingWriter { Ok(()) } } -fn serialized_size(value: &(impl Serialize + ?Sized)) -> Result { +pub(crate) fn serialized_size(value: &(impl Serialize + ?Sized)) -> Result { let mut counter = CountingWriter { bytes: 0 }; serde_json::to_writer(&mut counter, value).map_err(|_| ())?; Ok(counter.bytes) @@ -541,6 +570,7 @@ impl ReportBudget { result.value(&resolved.compiled.semantic_hash, resolved, "")?; result.value(&resolved.compiled.declared_workloads, resolved, "")?; result.value(&resolved.local_resolution, resolved, "")?; + result.value(&resolved.routing_resolution, resolved, "")?; result.value(&metadata.overlay, resolved, "")?; if let Some(host) = &resolved.compiled.plan.host { result.value(&host.targets(), resolved, "")?; diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs index 29626684f..422765bf7 100644 --- a/packages/config-compiler/src/lib.rs +++ b/packages/config-compiler/src/lib.rs @@ -4,6 +4,7 @@ pub mod host; mod json; pub mod local; pub mod model; +pub mod routing; mod shape; mod validate; pub use json::MAX_INPUT_BYTES; @@ -64,6 +65,15 @@ fn diagnostic_message(code: &str) -> &'static str { "invalid_path" => "The path must use the required portable relative or absolute form.", "unknown_env_target" => "The host environment target must name a declared workload.", "invalid_singleton" => "Singleton ports must be distinct nonzero ports.", + "invalid_domain" => "Use canonical DNS labels without wildcards or trailing dots.", + "invalid_origin" => "Use an HTTP origin without credentials, path, query or fragment.", + "invalid_route" => "The route requires a valid service port and hostname.", + "unknown_route_service" => "The route must target a declared service.", + "unknown_route_alias" => "The selected OAuth alias must be declared.", + "route_collision" => "Declared routes or aliases resolve to the same origin.", + "missing_open_alias" => { + "Alias open preference requires an explicitly selected OAuth alias." + } "unknown_profile" => "The profile must be declared by the project.", "duplicate_workload" => "Services and jobs must have distinct names.", "unknown_dependency" => { @@ -254,6 +264,18 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { host::HostSingleton::decl(&cfg), host::HostConflict::decl(&cfg), host::HostEnvTargets::decl(&cfg), + routing::Routes::decl(&cfg), + routing::RouteAlias::decl(&cfg), + routing::HttpRoute::decl(&cfg), + routing::HttpProtocol::decl(&cfg), + routing::OpenConfig::decl(&cfg), + routing::OpenPreference::decl(&cfg), + routing::LocalRoutes::decl(&cfg), + routing::LocalOpen::decl(&cfg), + routing::DomainOrigin::decl(&cfg), + routing::OpenPreferenceOrigin::decl(&cfg), + routing::ResolvedHttpRoute::decl(&cfg), + routing::RoutingResolution::decl(&cfg), WorktreePolicy::decl(&cfg), SourceMode::decl(&cfg), Source::decl(&cfg), @@ -300,5 +322,5 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { } pub fn protocol() -> Value { - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1,"host_env_plan_version":1,"routing_plan_version":1}) } diff --git a/packages/config-compiler/src/local.rs b/packages/config-compiler/src/local.rs index 70588ebb0..bcaa47d7c 100644 --- a/packages/config-compiler/src/local.rs +++ b/packages/config-compiler/src/local.rs @@ -37,6 +37,22 @@ pub struct LocalConfig { #[serde(default)] #[ts(optional, as = "Option")] pub environment: LocalEnvironment, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "crate::routing::LocalRoutes")] + #[ts(optional, type = "LocalRoutes")] + pub routes: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "crate::routing::LocalOpen")] + #[ts(optional, type = "LocalOpen")] + pub open: Option, } #[derive(Debug, Deserialize, Serialize, JsonSchema, TS)] @@ -70,6 +86,40 @@ pub struct ResolveRequest { #[schemars(with = "Option", regex(pattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$"))] #[ts(optional = nullable, as = "Option")] pub explicit_overlay: Option>, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub global_domain: Option, + + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub explicit_domain: Option, + + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub branch: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "bool")] + #[ts(optional, type = "boolean")] + pub routing_probe: Option, } #[derive(Debug, Clone, Copy, Serialize, JsonSchema, TS)] @@ -117,6 +167,12 @@ pub enum ResolveResult { #[ts(optional, type = "HostEnvTargets")] host_env_targets: Option, local_resolution: LocalResolution, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "RoutingResolution")] + routing_resolution: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "true")] + routing_inputs_required: Option, }, Failure { #[ts(type = "1")] @@ -170,9 +226,10 @@ pub(crate) fn decode( with_role(role, diagnostic_at(&document.positions, code, &pointer)) }) } -struct ParsedLocal { - config: LocalConfig, +pub(crate) struct ParsedLocal { + pub(crate) config: LocalConfig, overlay_location: ResolveDiagnostic, + pub(crate) positions: std::collections::BTreeMap, } fn read_local(text: &str, role: DocumentRole) -> Result { let document = json::parse(text.as_bytes()).map_err(|d| with_role(role, d))?; @@ -194,7 +251,23 @@ fn read_local(text: &str, role: DocumentRole) -> Result Result ResolveResult { semantic_hash: resolved.compiled.semantic_hash, declared_workloads: resolved.compiled.declared_workloads, local_resolution: resolved.local_resolution, + routing_resolution: resolved.routing_resolution.map(Box::new), + routing_inputs_required: resolved.routing_inputs_required, }, Err(diagnostic) => ResolveResult::Failure { transport_version: 1, @@ -252,6 +328,8 @@ pub(crate) struct Resolved { pub(crate) compiled: crate::Compiled, pub(crate) local_resolution: LocalResolution, pub(crate) overlay_location: ResolveDiagnostic, + pub(crate) routing_resolution: Option, + pub(crate) routing_inputs_required: Option, } fn resolve_inner(bytes: &[u8], profiles: &[String]) -> Result { let document = json::parse_with_limit(bytes, MAX_REQUEST_BYTES) @@ -278,6 +356,30 @@ pub(crate) fn resolve_document( } crate::shape::object(&document, "").map_err(|d| with_role(DocumentRole::Request, d))?; let request: ResolveRequest = decode(&document, DocumentRole::Request)?; + for (pointer, value) in [ + ("/global_domain", &request.global_domain), + ("/explicit_domain", &request.explicit_domain), + ] { + if value + .as_ref() + .is_some_and(|domain| !crate::routing::domain(domain)) + { + return Err(with_role( + DocumentRole::Request, + diagnostic_at(&document.positions, "invalid_domain", pointer), + )); + } + } + if request + .branch + .as_ref() + .is_some_and(|branch| !crate::routing::label(branch)) + { + return Err(with_role( + DocumentRole::Request, + diagnostic_at(&document.positions, "invalid_name", "/branch"), + )); + } let documents = [ (DocumentRole::Project, Some(request.project.as_str())), (DocumentRole::PrimaryLocal, request.primary_local.as_deref()), @@ -363,9 +465,26 @@ pub(crate) fn resolve_document( diagnostic_at(&document.positions, "missing_overlay", "/explicit_overlay"), ); } + let routing_context = crate::routing::Context { + compiled: &compiled, + request: &request, + primary: primary.as_ref(), + checkout: checkout.as_ref(), + overlay: &overlay, + }; + let routing_active = crate::routing::required(&routing_context); + let routing_inputs_required = + (request.routing_probe == Some(true) && routing_active).then_some(true); + let routing_resolution = if request.routing_probe == Some(true) { + None + } else { + crate::routing::resolve(routing_context)? + }; #[derive(Serialize)] struct ResolutionInputs<'a> { resolve_version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + routing_inputs: Option>, semantic_hash: &'a str, primary_local: Option<&'a LocalConfig>, checkout_local: Option<&'a LocalConfig>, @@ -374,6 +493,11 @@ pub(crate) fn resolve_document( } let encoded = serde_json::to_vec(&ResolutionInputs { resolve_version: 1, + routing_inputs: routing_active.then_some(crate::routing::InputGeneration { + global_domain: &request.global_domain, + explicit_domain: &request.explicit_domain, + branch: &request.branch, + }), semantic_hash, primary_local: primary.as_ref().map(|input| &input.config), checkout_local: checkout.as_ref().map(|input| &input.config), @@ -396,6 +520,8 @@ pub(crate) fn resolve_document( compiled, local_resolution, overlay_location, + routing_resolution, + routing_inputs_required, }) } diff --git a/packages/config-compiler/src/model.rs b/packages/config-compiler/src/model.rs index 2dbcd0416..69cb5aa5b 100644 --- a/packages/config-compiler/src/model.rs +++ b/packages/config-compiler/src/model.rs @@ -48,6 +48,22 @@ pub struct Project { #[schemars(with = "crate::host::HostConfig")] #[ts(optional, type = "HostConfig")] pub host: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "crate::routing::Routes")] + #[ts(optional, type = "Routes")] + pub routes: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "crate::routing::OpenConfig")] + #[ts(optional, type = "OpenConfig")] + pub open: Option, } fn enabled() -> bool { @@ -346,6 +362,13 @@ pub struct Plan { #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional, type = "HostConfig")] pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "Routes")] + pub routes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "OpenConfig")] + pub open: Option, + pub selected_profiles: Vec, pub storage: BTreeMap, pub services: BTreeMap, diff --git a/packages/config-compiler/src/routing.rs b/packages/config-compiler/src/routing.rs new file mode 100644 index 000000000..344a754bc --- /dev/null +++ b/packages/config-compiler/src/routing.rs @@ -0,0 +1,660 @@ +//! Pure domain and route intent. This module does not resolve DNS or claim TLS/OAuth admission. +use crate::{Diagnostic, json::child, model, validate}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{Ipv4Addr, Ipv6Addr}; +use ts_rs::TS; + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct Routes { + #[serde( + default, + deserialize_with = "model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub domain: Option, + #[serde( + default, + deserialize_with = "model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub origin: Option, + #[serde(default)] + #[ts(optional, as = "Option>")] + pub aliases: BTreeMap, + #[serde( + default, + deserialize_with = "model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub oauth_alias: Option, + #[serde(default)] + #[ts(optional, as = "Option>")] + pub http: BTreeMap, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(untagged, deny_unknown_fields)] +pub enum RouteAlias { + Domain { domain: String }, + Origin { origin: String }, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct HttpRoute { + pub service: String, + #[schemars(range(min = 1, max = 65535))] + pub port: u16, + #[serde(default)] + #[ts(optional, as = "Option")] + pub protocol: HttpProtocol, + pub hostname: String, +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum HttpProtocol { + #[default] + Http, + Https, +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct OpenConfig { + #[serde(default)] + #[ts(optional, as = "Option")] + pub prefer: OpenPreference, +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum OpenPreference { + #[default] + Auto, + Alias, + Dev, +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct LocalRoutes { + #[serde( + default, + deserialize_with = "model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub domain: Option, +} +// Local absence inherits; an explicitly empty object must not select the authored default. +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct LocalOpen { + #[serde( + default, + deserialize_with = "model::present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "OpenPreference")] + #[ts(optional, type = "OpenPreference")] + pub prefer: Option, +} +#[derive(Debug, Clone, Copy, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum DomainOrigin { + Default, + Global, + Project, + PrimaryLocal, + CheckoutLocal, + Explicit, +} +#[derive(Debug, Clone, Copy, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum OpenPreferenceOrigin { + Default, + Project, + PrimaryLocal, + CheckoutLocal, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +pub struct ResolvedHttpRoute { + pub service: String, + pub port: u16, + pub protocol: HttpProtocol, + pub origin: String, + pub aliases: BTreeMap, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +pub struct RoutingResolution { + pub domain: String, + pub domain_origin: DomainOrigin, + pub project_origin: String, + pub aliases: BTreeMap, + pub oauth_alias: Option, + pub open_preference: OpenPreference, + pub open_preference_origin: OpenPreferenceOrigin, + pub open_origin: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional, type = "string")] + pub branch: Option, + pub routes: BTreeMap, +} + +pub(crate) fn label(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && !value.starts_with('-') + && !value.ends_with('-') + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') +} +fn dns_labels(value: &str) -> bool { + value.len() <= 253 && value.split('.').all(label) +} +pub(crate) fn domain(value: &str) -> bool { + dns_labels(value) && (value == "hack" || value.contains('.')) && !numeric_last_label(value) +} +struct Origin { + scheme: String, + host: String, + port: Option, + ip: bool, +} +impl Origin { + fn text(&self) -> String { + format!( + "{}://{}{}", + self.scheme, + self.host, + self.port.map(|p| format!(":{p}")).unwrap_or_default() + ) + } +} +fn numeric_last_label(host: &str) -> bool { + let last = host.rsplit('.').next().unwrap_or_default(); + last.bytes().all(|b| b.is_ascii_digit()) + || last + .strip_prefix("0x") + .is_some_and(|value| value.bytes().all(|b| b.is_ascii_hexdigit())) +} +// URL origins serialize all IPv6 segments as hexadecimal, including IPv4-mapped addresses. +fn ipv6_host(address: Ipv6Addr) -> String { + let segments = address.segments(); + let mut best_start = 0; + let mut best_len = 0; + let mut i = 0; + while i < 8 { + if segments[i] != 0 { + i += 1; + continue; + } + let start = i; + while i < 8 && segments[i] == 0 { + i += 1; + } + if i - start > best_len { + best_start = start; + best_len = i - start; + } + } + let render = |values: &[u16]| { + values + .iter() + .map(|v| format!("{v:x}")) + .collect::>() + .join(":") + }; + if best_len < 2 { + render(&segments) + } else { + format!( + "{}::{}", + render(&segments[..best_start]), + render(&segments[best_start + best_len..]) + ) + } +} +fn parse_origin(value: &str) -> Option { + if !value.is_ascii() + || value + .bytes() + .any(|b| b.is_ascii_whitespace() || b.is_ascii_control()) + { + return None; + } + let (scheme, authority) = value.split_once("://")?; + if !matches!(scheme, "http" | "https") + || authority.is_empty() + || authority.contains(['@', '/', '?', '#', '*', '\\', '%']) + { + return None; + } + let (host, port, ip) = if let Some(rest) = authority.strip_prefix('[') { + let (address, suffix) = rest.split_once(']')?; + let address = address.parse::().ok()?; + let port = if suffix.is_empty() { + None + } else { + Some(suffix.strip_prefix(':')?) + }; + (format!("[{}]", ipv6_host(address)), port, true) + } else { + let (host, port) = match authority.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (authority, None), + }; + let host = host.to_ascii_lowercase(); + if let Ok(address) = host.parse::() { + (address.to_string(), port, true) + } else { + if !dns_labels(&host) || numeric_last_label(&host) { + return None; + } + (host, port, false) + } + }; + let port = match port { + Some(value) => { + if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let p = value.parse::().ok()?; + if p == 0 { + return None; + } + if (scheme == "http" && p == 80) || (scheme == "https" && p == 443) { + None + } else { + Some(p) + } + } + None => None, + }; + Some(Origin { + scheme: scheme.into(), + host, + port, + ip, + }) +} +fn origin(value: &str) -> Option { + Some(parse_origin(value)?.text()) +} +fn generated(project: &str, suffix: &str, branch: Option<&str>) -> Option { + if !label(project) { + return None; + } + let hostname = match branch { + Some(branch) => format!("{branch}.{project}.{suffix}"), + None => format!("{project}.{suffix}"), + }; + (dns_labels(&hostname) && !numeric_last_label(&hostname)).then(|| format!("https://{hostname}")) +} +fn expand(base: &str, hostname: &str) -> Option { + if hostname == "project" { + return Some(base.into()); + } + let mut origin = parse_origin(base)?; + if origin.ip { + return None; + } + origin.host = format!("{hostname}.{}", origin.host); + if !dns_labels(&origin.host) { + return None; + } + Some(origin.text()) +} +pub(crate) fn normalize( + routes: &mut Routes, + services: &BTreeSet, + at: &dyn Fn(&str, &str) -> Diagnostic, +) -> Result<(), Diagnostic> { + if routes.domain.as_ref().is_some_and(|v| !domain(v)) { + return Err(at("invalid_domain", "/routes/domain")); + } + if let Some(value) = &mut routes.origin { + *value = origin(value).ok_or_else(|| at("invalid_origin", "/routes/origin"))?; + } + let mut aliases = BTreeSet::new(); + for (name, alias) in &mut routes.aliases { + let pointer = child("/routes/aliases", name); + if !validate::name(name) { + return Err(at("invalid_name", &pointer)); + } + let normalized = match alias { + RouteAlias::Domain { domain: value } => { + if !domain(value) { + return Err(at("invalid_domain", &child(&pointer, "domain"))); + } + format!("domain:{value}") + } + RouteAlias::Origin { origin: value } => { + *value = origin(value) + .ok_or_else(|| at("invalid_origin", &child(&pointer, "origin")))?; + format!("origin:{value}") + } + }; + if !aliases.insert(normalized) { + return Err(at("route_collision", &pointer)); + } + } + if routes + .oauth_alias + .as_ref() + .is_some_and(|name| !routes.aliases.contains_key(name)) + { + return Err(at("unknown_route_alias", "/routes/oauth_alias")); + } + let mut hostnames = BTreeSet::new(); + for (name, route) in &routes.http { + let pointer = child("/routes/http", name); + if !validate::name(name) { + return Err(at("invalid_name", &pointer)); + } + if !services.contains(&route.service) { + return Err(at("unknown_route_service", &child(&pointer, "service"))); + } + if route.port == 0 { + return Err(at("invalid_route", &child(&pointer, "port"))); + } + if !dns_labels(&route.hostname) { + return Err(at("invalid_domain", &child(&pointer, "hostname"))); + } + if !hostnames.insert(route.hostname.as_str()) { + return Err(at("route_collision", &pointer)); + } + } + Ok(()) +} + +#[derive(Serialize)] +pub(crate) struct InputGeneration<'a> { + pub global_domain: &'a Option, + pub explicit_domain: &'a Option, + pub branch: &'a Option, +} +pub(crate) struct Context<'a> { + pub compiled: &'a crate::Compiled, + pub request: &'a crate::local::ResolveRequest, + pub primary: Option<&'a crate::local::ParsedLocal>, + pub checkout: Option<&'a crate::local::ParsedLocal>, + pub overlay: &'a Option, +} +fn at( + positions: &BTreeMap, + role: crate::local::DocumentRole, + code: &str, + pointer: &str, +) -> crate::local::ResolveDiagnostic { + crate::local::with_role(role, crate::diagnostic_at(positions, code, pointer)) +} +struct ExpansionBudget { + remaining: usize, +} +impl ExpansionBudget { + fn charge( + &mut self, + bytes: usize, + ctx: &Context<'_>, + pointer: &str, + ) -> Result<(), crate::local::ResolveDiagnostic> { + self.remaining = self.remaining.checked_sub(bytes).ok_or_else(|| { + at( + &ctx.compiled.positions, + crate::local::DocumentRole::Project, + "plan_too_large", + pointer, + ) + })?; + Ok(()) + } + fn value( + &mut self, + value: &impl Serialize, + ctx: &Context<'_>, + ) -> Result<(), crate::local::ResolveDiagnostic> { + let bytes = crate::environment::serialized_size(value).map_err(|_| { + at( + &ctx.compiled.positions, + crate::local::DocumentRole::Project, + "plan_too_large", + "/routes", + ) + })?; + self.charge(bytes, ctx, "/routes") + } +} +pub(crate) fn required(ctx: &Context<'_>) -> bool { + let plan = &ctx.compiled.plan; + let primary = if plan.worktree.inherit_local { + ctx.primary + } else { + None + }; + plan.routes.is_some() + || plan.open.is_some() + || ctx.request.global_domain.is_some() + || ctx.request.explicit_domain.is_some() + || ctx.request.branch.is_some() + || [primary, ctx.checkout].iter().any(|input| { + input.is_some_and(|local| local.config.routes.is_some() || local.config.open.is_some()) + }) +} +pub(crate) fn resolve( + ctx: Context<'_>, +) -> Result, crate::local::ResolveDiagnostic> { + use crate::local::DocumentRole; + let plan = &ctx.compiled.plan; + let primary = if plan.worktree.inherit_local { + ctx.primary + } else { + None + }; + let locals = [ + ( + primary, + DocumentRole::PrimaryLocal, + DomainOrigin::PrimaryLocal, + OpenPreferenceOrigin::PrimaryLocal, + ), + ( + ctx.checkout, + DocumentRole::CheckoutLocal, + DomainOrigin::CheckoutLocal, + OpenPreferenceOrigin::CheckoutLocal, + ), + ]; + if !required(&ctx) { + return Ok(None); + } + let mut budget = ExpansionBudget { + remaining: crate::environment::MAX_PLAN_OUTPUT_BYTES, + }; + budget.charge(4096, &ctx, "/routes")?; + budget.value(plan, &ctx)?; + budget.value(&ctx.compiled.declared_workloads, &ctx)?; + budget.value(ctx.overlay, &ctx)?; + let mut domain_value = "hack.local".to_owned(); + let mut domain_origin = DomainOrigin::Default; + if let Some(value) = &ctx.request.global_domain { + domain_value = value.clone(); + domain_origin = DomainOrigin::Global; + } + if let Some(value) = plan.routes.as_ref().and_then(|r| r.domain.as_ref()) { + domain_value = value.clone(); + domain_origin = DomainOrigin::Project; + } + let mut preference = plan + .open + .as_ref() + .map(|o| o.prefer.clone()) + .unwrap_or_default(); + let mut preference_origin = if plan.open.is_some() { + OpenPreferenceOrigin::Project + } else { + OpenPreferenceOrigin::Default + }; + let mut preference_location = at( + &ctx.compiled.positions, + DocumentRole::Project, + "missing_open_alias", + "/open/prefer", + ); + for (input, role, domain_source, open_source) in locals { + if let Some(input) = input { + if let Some(value) = input.config.routes.as_ref().and_then(|r| r.domain.as_ref()) { + domain_value = value.clone(); + domain_origin = domain_source; + } + if let Some(value) = input.config.open.as_ref().and_then(|o| o.prefer.as_ref()) { + preference = value.clone(); + preference_origin = open_source; + preference_location = + at(&input.positions, role, "missing_open_alias", "/open/prefer"); + } + } + } + if let Some(value) = &ctx.request.explicit_domain { + domain_value = value.clone(); + domain_origin = DomainOrigin::Explicit; + } + let project_origin = if let Some(value) = plan.routes.as_ref().and_then(|r| r.origin.as_ref()) { + value.clone() + } else { + generated(&plan.name, &domain_value, ctx.request.branch.as_deref()).ok_or_else(|| { + at( + &ctx.compiled.positions, + DocumentRole::Project, + "invalid_origin", + "/name", + ) + })? + }; + budget.charge( + project_origin.len() * 2 + domain_value.len() + 128, + &ctx, + "/routes", + )?; + let mut aliases = BTreeMap::new(); + let mut bases = BTreeSet::from([project_origin.clone()]); + if let Some(routes) = &plan.routes { + for (name, alias) in &routes.aliases { + let pointer = child("/routes/aliases", name); + let value = match alias { + RouteAlias::Domain { domain } => { + generated(&plan.name, domain, ctx.request.branch.as_deref()).ok_or_else( + || { + at( + &ctx.compiled.positions, + DocumentRole::Project, + "invalid_origin", + &pointer, + ) + }, + )? + } + RouteAlias::Origin { origin } => origin.clone(), + }; + budget.charge(value.len() * 2 + name.len() + 128, &ctx, &pointer)?; + if !bases.insert(value.clone()) { + return Err(at( + &ctx.compiled.positions, + DocumentRole::Project, + "route_collision", + &pointer, + )); + } + aliases.insert(name.clone(), value); + } + } + let oauth_alias = plan.routes.as_ref().and_then(|r| r.oauth_alias.clone()); + let selected_alias = oauth_alias.as_ref().and_then(|name| aliases.get(name)); + let open_origin = match preference { + OpenPreference::Alias => selected_alias.ok_or(preference_location)?.clone(), + OpenPreference::Auto => selected_alias.unwrap_or(&project_origin).clone(), + OpenPreference::Dev => project_origin.clone(), + }; + let mut routes_out = BTreeMap::new(); + let mut claimed = BTreeSet::new(); + if let Some(routes) = &plan.routes { + for (name, route) in &routes.http { + let pointer = child("/routes/http", name); + let origin = expand(&project_origin, &route.hostname).ok_or_else(|| { + at( + &ctx.compiled.positions, + DocumentRole::Project, + "invalid_origin", + &child(&pointer, "hostname"), + ) + })?; + budget.charge( + origin.len() * 2 + name.len() + route.service.len() + 128, + &ctx, + &pointer, + )?; + if !claimed.insert(origin.clone()) { + return Err(at( + &ctx.compiled.positions, + DocumentRole::Project, + "route_collision", + &pointer, + )); + } + let selected = plan.services.contains_key(&route.service); + let mut route_aliases = BTreeMap::new(); + for (alias, base) in &aliases { + let alias_origin = expand(base, &route.hostname).ok_or_else(|| { + at( + &ctx.compiled.positions, + DocumentRole::Project, + "invalid_origin", + &child(&pointer, "hostname"), + ) + })?; + budget.charge(alias_origin.len() * 2 + alias.len() + 64, &ctx, &pointer)?; + if !claimed.insert(alias_origin.clone()) { + return Err(at( + &ctx.compiled.positions, + DocumentRole::Project, + "route_collision", + &pointer, + )); + } + if selected { + route_aliases.insert(alias.clone(), alias_origin); + } + } + if selected { + routes_out.insert( + name.clone(), + ResolvedHttpRoute { + service: route.service.clone(), + port: route.port, + protocol: route.protocol.clone(), + origin, + aliases: route_aliases, + }, + ); + } + } + } + Ok(Some(RoutingResolution { + domain: domain_value, + domain_origin, + project_origin, + aliases, + oauth_alias, + open_preference: preference, + open_preference_origin: preference_origin, + open_origin, + branch: ctx.request.branch.clone(), + routes: routes_out, + })) +} diff --git a/packages/config-compiler/src/shape.rs b/packages/config-compiler/src/shape.rs index d785bac87..683f86228 100644 --- a/packages/config-compiler/src/shape.rs +++ b/packages/config-compiler/src/shape.rs @@ -21,7 +21,7 @@ fn optional_object(document: &json::Document, pointer: &str) -> Result<(), Diagn pub(crate) fn project(document: &json::Document) -> Result<(), Diagnostic> { object(document, "")?; - for key in ["source", "environment", "worktree"] { + for key in ["source", "environment", "worktree", "routes", "open"] { optional_object(document, &json::child("", key))?; } if let Some(storage) = document.value.get("storage").and_then(Value::as_object) { @@ -83,6 +83,20 @@ pub(crate) fn project(document: &json::Document) -> Result<(), Diagnostic> { host_invocation(document, &json::child("/host/processes", name))?; } } + for namespace in ["aliases", "http"] { + if let Some(entries) = document + .value + .pointer(&format!("/routes/{namespace}")) + .and_then(Value::as_object) + { + for name in entries.keys() { + object( + document, + &json::child(&format!("/routes/{namespace}"), name), + )?; + } + } + } Ok(()) } diff --git a/packages/config-compiler/src/validate.rs b/packages/config-compiler/src/validate.rs index 4d977e0fb..831511131 100644 --- a/packages/config-compiler/src/validate.rs +++ b/packages/config-compiler/src/validate.rs @@ -193,6 +193,9 @@ pub fn lower(mut project: Project, profiles: &[String], at: &At) -> Result Result Value { + json!({"schema_version":1,"name":"example","profiles":["dev"],"services":{"web":{"image":"web:1"},"api":{"image":"api:1","profiles":["dev"]}},"jobs":{"init":{"image":"init:1"}}}) +} +fn route(service: &str, hostname: &str) -> Value { + json!({"service":service,"port":3000,"hostname":hostname}) +} +fn compile_value(p: &Value) -> Value { + serde_json::to_value(compile(p.to_string().as_bytes(), &[])).unwrap() +} +fn request(p: &Value) -> Value { + json!({"request_version":1,"project":p.to_string()}) +} +fn result(r: &Value) -> Value { + serde_json::to_value(resolve(r.to_string().as_bytes(), &[])).unwrap() +} +fn local(domain: &str) -> Value { + json!({"schema_version":1,"routes":{"domain":domain}}) +} +#[test] +fn unrouted_wire_is_unchanged_and_explicit_context_can_preview_origin() { + let p = project(); + let base = compile_value(&p); + assert!(base["plan"].get("routes").is_none()); + assert!(base["plan"].get("open").is_none()); + let r = request(&p); + let unresolved = result(&r); + assert!(unresolved.get("routing_resolution").is_none()); + let mut r = r; + r["global_domain"] = json!("custom.test"); + let preview = result(&r); + assert_eq!( + preview["routing_resolution"]["project_origin"], + "https://example.custom.test" + ); + assert_eq!(preview["routing_resolution"]["routes"], json!({})); + assert_eq!(preview["semantic_hash"], unresolved["semantic_hash"]); + assert_ne!( + preview["local_resolution"]["resolution_hash"], + unresolved["local_resolution"]["resolution_hash"] + ); +} +#[test] +fn domain_precedence_and_generation_are_separate_from_authored_identity() { + let mut p = project(); + p["routes"] = json!({"domain":"project.test"}); + let mut r = request(&p); + r["global_domain"] = json!("global.test"); + r["primary_local"] = json!(local("primary.test").to_string()); + r["checkout_local"] = json!(local("checkout.test").to_string()); + r["explicit_domain"] = json!("explicit.test"); + r["branch"] = json!("feature"); + let out = result(&r); + assert_eq!(out["routing_resolution"]["domain_origin"], "explicit"); + assert_eq!( + out["routing_resolution"]["project_origin"], + "https://feature.example.explicit.test" + ); + assert_eq!(out["routing_resolution"]["branch"], "feature"); + let first = out["local_resolution"]["resolution_hash"].clone(); + r["global_domain"] = json!("shadowed.test"); + let out = result(&r); + assert_ne!(out["local_resolution"]["resolution_hash"], first); + assert_eq!(out["semantic_hash"], compile_value(&p)["semantic_hash"]); + for (field, origin, domain) in [ + ("explicit_domain", "checkout_local", "checkout.test"), + ("checkout_local", "primary_local", "primary.test"), + ("primary_local", "project", "project.test"), + ] { + r.as_object_mut().unwrap().remove(field); + let out = result(&r); + assert_eq!(out["routing_resolution"]["domain_origin"], origin); + assert_eq!(out["routing_resolution"]["domain"], domain); + } + p["routes"] = json!({}); + r["project"] = json!(p.to_string()); + assert_eq!(result(&r)["routing_resolution"]["domain_origin"], "global"); + r.as_object_mut().unwrap().remove("global_domain"); + assert_eq!(result(&r)["routing_resolution"]["domain"], "hack.local"); +} +#[test] +fn optout_ignores_but_still_validates_primary_local_and_empty_fields_inherit() { + let mut p = project(); + p["routes"] = json!({"domain":"project.test"}); + p["worktree"] = json!({"inherit_local":false}); + let mut r = request(&p); + r["primary_local"] = json!(local("primary.test").to_string()); + r["checkout_local"] = json!(json!({"schema_version":1,"routes":{},"open":{}}).to_string()); + let out = result(&r); + assert_eq!(out["routing_resolution"]["domain"], "project.test"); + r["primary_local"] = json!(local("INVALID.test").to_string()); + let out = result(&r); + assert_eq!(out["ok"], false); + assert_eq!(out["diagnostics"][0]["document"], "primary_local"); +} +#[test] +fn explicit_origins_and_aliases_remain_pinned_while_generated_aliases_follow_branch() { + let mut p = project(); + p["routes"] = json!({"origin":"http://LOCALHOST:80","aliases":{"oauth":{"origin":"https://LOGIN.example.test:443"},"dev":{"domain":"hack.gy"}},"oauth_alias":"oauth","http":{"web":route("web","project"),"api":route("api","api")}}); + let mut r = request(&p); + r["explicit_domain"] = json!("ignored.test"); + r["branch"] = json!("feature"); + let out = result(&r); + assert_eq!(out["ok"], true, "{out}"); + let routing = &out["routing_resolution"]; + assert_eq!(routing["project_origin"], "http://localhost"); + assert_eq!(routing["aliases"]["oauth"], "https://login.example.test"); + assert_eq!(routing["aliases"]["dev"], "https://feature.example.hack.gy"); + assert_eq!(routing["open_origin"], "https://login.example.test"); + assert!(routing["routes"].get("api").is_none()); + assert_eq!(out["plan"]["routes"]["http"].as_object().unwrap().len(), 2); +} +#[test] +fn open_preferences_use_only_explicit_oauth_selection_with_local_provenance() { + let mut p = project(); + p["routes"] = json!({"aliases":{"oauth":{"domain":"hack.gy"}}}); + p["open"] = json!({"prefer":"alias"}); + let mut r = request(&p); + let out = result(&r); + assert_eq!(compile_value(&p)["ok"], true); + assert_eq!(out["diagnostics"][0]["code"], "missing_open_alias"); + p["routes"]["oauth_alias"] = json!("oauth"); + r["project"] = json!(p.to_string()); + assert_eq!( + result(&r)["routing_resolution"]["open_origin"], + "https://example.hack.gy" + ); + r["primary_local"] = json!(json!({"schema_version":1,"open":{"prefer":"dev"}}).to_string()); + let out = result(&r); + assert_eq!( + out["routing_resolution"]["open_preference_origin"], + "primary_local" + ); + assert_eq!( + out["routing_resolution"]["open_origin"], + "https://example.hack.local" + ); + r["checkout_local"] = json!(json!({"schema_version":1,"open":{"prefer":"auto"}}).to_string()); + assert_eq!( + result(&r)["routing_resolution"]["open_origin"], + "https://example.hack.gy" + ); + p["routes"].as_object_mut().unwrap().remove("oauth_alias"); + r["project"] = json!(p.to_string()); + r["checkout_local"] = json!("{\n\"schema_version\":1,\n\"open\":{\"prefer\":\"alias\"}\n}"); + let out = result(&r); + assert_eq!(out["diagnostics"][0]["document"], "checkout_local"); + assert_eq!(out["diagnostics"][0]["pointer"], "/open/prefer"); + assert_eq!(out["diagnostics"][0]["line"], 3); +} +#[test] +fn origin_grammar_supports_loopback_and_rejects_ambiguous_authorities() { + for (input, expected) in [ + ("http://127.0.0.1:8080", "http://127.0.0.1:8080"), + ("http://[::1]:80", "http://[::1]"), + ("http://[::ffff:127.0.0.1]", "http://[::ffff:7f00:1]"), + ("https://[0:0:0:0:0:0:0:1]:443", "https://[::1]"), + ("https://EXAMPLE.test:0443", "https://example.test"), + ] { + let mut p = project(); + p["routes"] = json!({"origin":input,"http":{"web":route("web","project")}}); + let out = result(&request(&p)); + assert_eq!(out["ok"], true, "{out}"); + assert_eq!(out["routing_resolution"]["project_origin"], expected); + } + for input in [ + "http://user:pass@example.test", + "https://example.test/", + "https://example.test/path", + "https://example.test?x=1", + "https://example.test#x", + "https://*.example.test", + "https://example.test\\evil", + "https://example.test\n", + "http://127.00.0.1", + "http://127.1", + "http://2130706433", + "http://0x7f000001", + "http://example.123", + "http://[::1%lo0]", + "http://localhost:0", + "http://localhost:65536", + "file://localhost", + "https://example.test.", + ] { + let mut p = project(); + p["routes"] = json!({"origin":input}); + let out = compile_value(&p); + assert_eq!(out["ok"], false, "accepted {input}"); + assert_eq!(out["diagnostics"][0]["code"], "invalid_origin"); + } + let mut p = project(); + p["routes"] = json!({"origin":"http://127.0.0.1","http":{"web":route("web","api")}}); + assert_eq!(result(&request(&p))["ok"], false); +} +#[test] +fn collisions_and_service_references_are_validated_before_filtering() { + for target in ["missing", "init"] { + let mut p = project(); + p["routes"] = json!({"http":{"route":route(target,"project")}}); + assert_eq!( + compile_value(&p)["diagnostics"][0]["code"], + "unknown_route_service" + ); + } + let mut p = project(); + p["routes"] = + json!({"http":{"active":route("web","project"),"inactive":route("api","project")}}); + assert_eq!( + compile_value(&p)["diagnostics"][0]["code"], + "route_collision" + ); + p["routes"] = json!({"aliases":{"overlap":{"origin":"https://api.example.hack.local"}},"http":{"base":route("api","project"),"api":route("web","api")}}); + assert_eq!( + result(&request(&p))["diagnostics"][0]["code"], + "route_collision" + ); + p["routes"] = json!({"aliases":{"same":{"domain":"hack.local"}}}); + assert_eq!( + result(&request(&p))["diagnostics"][0]["code"], + "route_collision" + ); + p["routes"] = + json!({"aliases":{"a":{"origin":"https://X.test:443"},"b":{"origin":"https://x.test"}}}); + assert_eq!( + compile_value(&p)["diagnostics"][0]["code"], + "route_collision" + ); +} +#[test] +fn request_and_local_fields_are_strict_even_when_shadowed() { + let mut p = project(); + p["routes"] = json!({}); + for key in ["global_domain", "explicit_domain", "branch"] { + for value in [Value::Null, json!("BAD_NAME"), json!([])] { + let mut r = request(&p); + r[key] = value; + let out = result(&r); + assert_eq!(out["ok"], false); + assert_eq!(out["diagnostics"][0]["document"], "request"); + } + } + for role in ["primary_local", "checkout_local"] { + for local in [ + json!({"schema_version":1,"routes":{"domain":null}}), + json!({"schema_version":1,"routes":{"origin":"https://private-sentinel.test"}}), + json!({"schema_version":1,"open":null}), + json!({"schema_version":1,"open":{"prefer":null}}), + json!({"schema_version":1,"open":[]}), + ] { + let mut r = request(&p); + r[role] = json!(local.to_string()); + let out = result(&r); + assert_eq!(out["ok"], false); + assert_eq!(out["diagnostics"][0]["document"], role); + assert!(!out.to_string().contains("private-sentinel")); + } + } +} +#[test] +fn alias_route_expansion_is_bounded_before_allocating_cartesian_output() { + let mut p = project(); + let mut aliases = serde_json::Map::new(); + let mut http = serde_json::Map::new(); + for i in 0..400 { + aliases.insert(format!("a{i}"), json!({"domain":format!("a{i}.test")})); + http.insert(format!("r{i}"), route("web", &format!("r{i}"))); + } + p["routes"] = json!({"aliases":aliases,"http":http}); + assert!(p.to_string().len() < hack_config_compiler::MAX_INPUT_BYTES); + let out = result(&request(&p)); + assert_eq!(out["ok"], false); + assert_eq!(out["diagnostics"][0]["code"], "plan_too_large"); + assert!(out.to_string().len() < 1024); +} +#[test] +fn planning_preserves_routing_hashes_and_symbolic_environment_only() { + let mut p = project(); + p["routes"] = json!({"http":{"web":route("web","project")}}); + let mut r = request(&p); + let resolved = result(&r); + r["env_metadata"] = json!({"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{},"api":{},"init":{}},"inactive_scopes":[]}); + let planned = serde_json::to_value(plan(r.to_string().as_bytes(), &[])).unwrap(); + assert_eq!(planned["ok"], true); + assert_eq!( + planned["routing_resolution"], + resolved["routing_resolution"] + ); + assert_eq!(planned["local_resolution"], resolved["local_resolution"]); + assert_eq!(planned["semantic_hash"], resolved["semantic_hash"]); +} + +#[test] +fn domain_suffixes_are_distinct_from_relative_route_hostnames() { + for suffix in [ + "dev", + "local", + "localhost", + "123", + "0x7f", + "example.123", + "example.0x7f", + "bad_name.test", + ] { + let mut p = project(); + p["routes"] = json!({"domain":suffix}); + assert_eq!( + compile_value(&p)["diagnostics"][0]["code"], + "invalid_domain" + ); + let mut p = project(); + p["routes"] = json!({"aliases":{"oauth":{"domain":suffix}}}); + assert_eq!(compile_value(&p)["ok"], false); + } + let mut p = project(); + p["routes"] = json!({"domain":"hack","http":{"web":route("web","123")}}); + let out = result(&request(&p)); + assert_eq!(out["ok"], true); + assert_eq!( + out["routing_resolution"]["routes"]["web"]["origin"], + "https://123.example.hack" + ); +} + +#[test] +fn routing_probe_defers_context_dependent_collisions_without_changing_hashes() { + let mut p = project(); + p["routes"] = json!({"aliases":{"oauth":{"domain":"hack.local"}}}); + let mut r = request(&p); + assert_eq!(result(&r)["diagnostics"][0]["code"], "route_collision"); + r["routing_probe"] = json!(true); + let probe = result(&r); + assert_eq!(probe["ok"], true); + assert_eq!(probe["routing_inputs_required"], true); + assert!(probe.get("routing_resolution").is_none()); + r["global_domain"] = json!("custom.test"); + let probe = result(&r); + r.as_object_mut().unwrap().remove("routing_probe"); + let final_result = result(&r); + assert_eq!(final_result["ok"], true); + assert_eq!(probe["local_resolution"], final_result["local_resolution"]); + p["routes"] = json!({"aliases":{"fixed":{"origin":"https://example.hack.local"}}}); + r = request(&p); + r["routing_probe"] = json!(true); + assert_eq!(result(&r)["routing_inputs_required"], true); + r.as_object_mut().unwrap().remove("routing_probe"); + r["branch"] = json!("feature"); + assert_eq!(result(&r)["ok"], true); + let mut no_routing = request(&project()); + let expected = result(&no_routing); + no_routing["routing_probe"] = json!(true); + assert_eq!(result(&no_routing), expected); + for value in [true, false] { + let mut plan_request = request(&project()); + plan_request["routing_probe"] = json!(value); + plan_request["env_metadata"] = json!({"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{},"api":{},"init":{}},"inactive_scopes":[]}); + let out = serde_json::to_value(plan(plan_request.to_string().as_bytes(), &[])).unwrap(); + assert_eq!(out["ok"], false); + assert_eq!(out["diagnostics"][0]["pointer"], "/routing_probe"); + } +} diff --git a/scripts/check-native-routing-plan-cli.ts b/scripts/check-native-routing-plan-cli.ts new file mode 100644 index 000000000..46ea70d25 --- /dev/null +++ b/scripts/check-native-routing-plan-cli.ts @@ -0,0 +1,499 @@ +#!/usr/bin/env bun +import { + copyFile, + mkdir, + mkdtemp, + readdir, + realpath, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; + +/** Exercise the relocated executable/sidecar with isolated policy and real linked Git worktrees. */ +const root = resolve(import.meta.dir, ".."); +const directory = await realpath( + await mkdtemp(join(tmpdir(), "hack-routing-cli-")) +); +const bundle = join(directory, "bundle"); +const home = join(directory, "home"); +const primary = join(directory, "primary"); +const checkout = join(directory, "checkout"); +const marker = join(directory, "hook-must-not-run"); +const canary = "private-synthetic-routing-value"; +const project = { + schema_version: 1, + name: "routing-fixture", + profiles: ["optional"], + services: { + web: { image: "example/web:1" }, + admin: { image: "example/admin:1", profiles: ["optional"] }, + }, + host: { + up: { + before: [ + { name: "prepare", command: { exec: ["/usr/bin/touch", marker] } }, + ], + }, + }, + routes: { + domain: "project.example", + aliases: { + oauth: { domain: "hack.gy" }, + loopback: { origin: "http://localhost:3044" }, + }, + oauth_alias: "oauth", + http: { + web: { service: "web", port: 3000, hostname: "project" }, + admin: { + service: "admin", + port: 4000, + protocol: "https", + hostname: "admin", + }, + }, + }, +}; +let checks = 0; +try { + await Promise.all([ + mkdir(bundle), + mkdir(join(home, ".hack"), { recursive: true }), + mkdir(join(primary, ".hack"), { recursive: true }), + ]); + for (const name of ["hack", "hack-config-compiler"]) { + await copyFile(join(root, "dist", name), join(bundle, name)); + } + const globalPath = join(home, ".hack/hack.config.json"); + await Bun.write( + globalPath, + JSON.stringify({ + default_domain: "global.example", + ignored_private_field: canary, + }) + ); + await authored(primary, project); + await Bun.write( + join(primary, ".hack/hack.env.default.yaml"), + JSON.stringify({ + version: 1, + environment: "default", + secretsprovider: "project_key", + values: { global: { TOKEN: { secure: canary } } }, + }) + ); + await mkdir(join(primary, ".hack/.hack.secret.key")); + const first = await validate(primary); + const firstRoute = routing(first); + assert( + firstRoute.domain === "project.example" && + firstRoute.domain_origin === "project", + "project domain beats global policy" + ); + assert( + firstRoute.project_origin === "https://routing-fixture.project.example" && + firstRoute.open_origin === "https://routing-fixture.hack.gy", + "explicit OAuth alias selection controls auto open" + ); + assert( + Object.keys(record(firstRoute.routes)).join() === "web", + "inactive route omitted from selected report" + ); + assert( + record(record(record(firstRoute.routes).web).aliases).loopback === + "http://localhost:3044", + "explicit loopback alias preserved" + ); + const profiles = await validate(primary, ["--profile", "optional"]); + assert( + record(record(routing(profiles).routes).admin).origin === + "https://admin.routing-fixture.project.example", + "profiled named route expands centrally" + ); + const planned = await invoke(["config", "plan", "--json"], primary); + assert( + planned.exit === 0 && + JSON.stringify(planned.value.routing_resolution) === + JSON.stringify(first.routing_resolution) && + record(planned.value.local_resolution).resolution_hash === + record(first.local_resolution).resolution_hash, + "plan and validate use identical policy generation" + ); + // This alias collides only with the provisional built-in domain. Actual global + // selection must be acquired before Rust decides generated-origin collisions. + await authored(primary, { + ...project, + routes: { + aliases: { local: { domain: "hack.local" } }, + http: project.routes.http, + }, + }); + const globalCollisionResolved = await validate(primary); + assert( + routing(globalCollisionResolved).domain === "global.example" && + routing(globalCollisionResolved).project_origin === + "https://routing-fixture.global.example" && + record(routing(globalCollisionResolved).aliases).local === + "https://routing-fixture.hack.local", + "selected global domain resolves provisional built-in-domain alias collision" + ); + const globalCollisionPlan = await invoke( + ["config", "plan", "--json"], + primary + ); + assert( + globalCollisionPlan.exit === 0 && + JSON.stringify(globalCollisionPlan.value.routing_resolution) === + JSON.stringify(globalCollisionResolved.routing_resolution), + "planning uses actual global selection before collision validation" + ); + await authored(primary, project); + await local(primary, "primary.example", "dev"); + await git(["init", "-b", "main"], primary); + await git(["add", ".hack/hack.project.json"], primary); + await git( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "-m", + "fixture", + ], + primary + ); + await git(["worktree", "add", "-b", "feature/routing", checkout], primary); + const inherited = await validate(checkout); + assert( + routing(inherited).domain === "primary.example" && + routing(inherited).domain_origin === "primary_local" && + routing(inherited).branch === "feature-routing", + "verified linked primary settings and branch namespace" + ); + assert( + routing(inherited).project_origin === + "https://feature-routing.routing-fixture.primary.example", + "linked origins remain isolated" + ); + // The pinned alias equals the unbranched origin, but is distinct from this + // verified linked worktree's generated origin. Probe must not reject it early. + await authored(checkout, { + ...project, + routes: { + domain: "project.example", + aliases: { base: { origin: "https://routing-fixture.primary.example" } }, + http: project.routes.http, + }, + }); + const branchCollisionResolved = await validate(checkout); + assert( + routing(branchCollisionResolved).branch === "feature-routing" && + routing(branchCollisionResolved).project_origin === + "https://feature-routing.routing-fixture.primary.example" && + record(routing(branchCollisionResolved).aliases).base === + "https://routing-fixture.primary.example", + "verified branch resolves provisional unbranched pinned-alias collision" + ); + const branchCollisionPlan = await invoke( + ["config", "plan", "--json"], + checkout + ); + assert( + branchCollisionPlan.exit === 0 && + JSON.stringify(branchCollisionPlan.value.routing_resolution) === + JSON.stringify(branchCollisionResolved.routing_resolution), + "planning uses verified branch selection before collision validation" + ); + await authored(checkout, project); + await local(checkout, "current.example", "alias"); + const current = await validate(checkout); + assert( + routing(current).domain === "current.example" && + routing(current).domain_origin === "checkout_local", + "current local replaces inherited domain" + ); + const explicit = await validate(checkout, ["--domain", "cli.example"]); + assert( + routing(explicit).domain === "cli.example" && + routing(explicit).domain_origin === "explicit" && + explicit.semantic_hash === current.semantic_hash && + record(explicit.local_resolution).resolution_hash !== + record(current.local_resolution).resolution_hash, + "CLI override changes local generation only" + ); + const explicitPlan = await invoke( + ["config", "plan", "--json", "--domain", "cli.example"], + checkout + ); + assert( + explicitPlan.exit === 0 && + JSON.stringify(explicitPlan.value.routing_resolution) === + JSON.stringify(explicit.routing_resolution), + "CLI planning forwards explicit domain" + ); + for (const extra of [{ CI: "1" }, { HACK_EXECUTION_MODE: "codex" }]) { + const excluded = await validate(checkout, [], extra); + assert( + routing(excluded).branch === undefined && + routing(excluded).domain === "current.example", + "runner exclusions retain checkout policy without branch inheritance" + ); + } + await authored(checkout, { + ...project, + worktree: { inherit_local: false, auto_branch: false }, + }); + await rm(join(checkout, ".hack/hack.local.json")); + const optedOut = await validate(checkout); + assert( + routing(optedOut).branch === undefined && + routing(optedOut).domain === "project.example", + "worktree opt-out excludes inherited settings and auto branch" + ); + const pinnedProject = { + ...project, + routes: { ...project.routes, origin: "https://fixed.example:19443" }, + }; + await authored(checkout, pinnedProject); + const pinned = await validate(checkout, ["--domain", "cli.example"]); + assert( + routing(pinned).project_origin === "https://fixed.example:19443" && + record(routing(pinned).aliases).oauth === + "https://feature-routing.routing-fixture.hack.gy", + "explicit project origin wins while generated aliases use verified namespace" + ); + await authored(checkout, project); + await git(["checkout", "--detach"], checkout); + const detached = await invoke(["config", "validate", "--json"], checkout); + assert( + detached.exit === 1 && + record(detached.value.error).code === "E_CONFIG_INVALID", + "detached linked checkout refuses implicit base collision" + ); + await git(["checkout", "feature/routing"], checkout); + const poisoned = `invalid: [${canary}`; + await Bun.write(join(checkout, ".hack/hack.env.default.yaml"), poisoned); + const offline = await validate(checkout); + assert(offline.ok === true, "offline validation never reads managed YAML"); + const metadata = await invoke(["config", "plan", "--json"], checkout); + assert( + metadata.exit === 1 && + record(metadata.value.error).code === "E_CONFIG_METADATA", + "selected malformed managed document fails privately" + ); + await Bun.write(globalPath, poisoned); + const contextFree = await invoke( + [ + "config", + "validate", + "--file", + join(checkout, ".hack/hack.project.json"), + "--json", + ], + checkout + ); + assert( + contextFree.exit === 0 && + contextFree.value.routing_resolution === undefined, + "explicit-file validation is independent of global/local context" + ); + const badPolicy = await invoke(["config", "validate", "--json"], checkout); + assert( + badPolicy.exit === 1 && + record(badPolicy.value.error).code === "E_CONFIG_INPUT", + "malformed global policy fails privately" + ); + await Bun.write( + globalPath, + JSON.stringify({ default_domain: "global.example" }) + ); + for (const [label, value] of [ + [ + "inactive collision", + { + ...project, + routes: { + ...project.routes, + http: { + ...project.routes.http, + admin: { ...project.routes.http.admin, hostname: "project" }, + }, + }, + }, + ], + [ + "undeclared target", + { + ...project, + routes: { + ...project.routes, + http: { + web: { service: "missing", port: 3000, hostname: "project" }, + }, + }, + }, + ], + [ + "credential origin", + { + ...project, + routes: { + ...project.routes, + origin: `https://${canary}@fixed.example`, + }, + }, + ], + [ + "duplicate normalized alias", + { + ...project, + routes: { + ...project.routes, + aliases: { + first: { origin: "HTTPS://fixed.example:443" }, + second: { origin: "https://fixed.example" }, + }, + oauth_alias: "first", + }, + }, + ], + ] as const) { + await authored(checkout, value); + const invalid = await invoke(["config", "validate", "--json"], checkout); + assert( + invalid.exit === 1 && invalid.value.ok === false, + `${label} refused independent of profile` + ); + } + await authored(checkout, project); + await Bun.write( + join(checkout, ".hack/hack.local.json"), + JSON.stringify({ schema_version: 1, routes: { http: project.routes.http } }) + ); + const injection = await invoke(["config", "validate", "--json"], checkout); + assert( + injection.exit === 1 && injection.value.ok === false, + "local route injection refused" + ); + await rm(join(checkout, ".hack/hack.local.json")); + const runtime = await invoke(["up", "--json"], checkout); + assert( + runtime.exit !== 0 && + (runtime.stdout + runtime.stderr).includes( + "E_NATIVE_PROJECT_UNSUPPORTED" + ), + "native execution remains fenced" + ); + assert( + !( + (await Bun.file(marker).exists()) || + (await readdir(join(checkout, ".hack"))).some( + (name) => name === ".internal" || name === ".branch" + ) + ) && (await readdir(join(home, ".hack"))).join() === "hack.config.json", + "no hook, registry, route publication or generated-state effects" + ); + process.stdout.write( + `Relocated native routing planning: ${checks} checks passed; no DNS/trust/runtime effects\n` + ); +} finally { + await rm(directory, { recursive: true, force: true }); +} + +async function authored(cwd: string, value: unknown) { + await Bun.write(join(cwd, ".hack/hack.project.json"), JSON.stringify(value)); +} +async function local(cwd: string, domain: string, prefer: string) { + await Bun.write( + join(cwd, ".hack/hack.local.json"), + JSON.stringify({ schema_version: 1, routes: { domain }, open: { prefer } }) + ); +} +async function validate( + cwd: string, + args: readonly string[] = [], + extra: Readonly> = {} +) { + const result = await invoke( + ["config", "validate", "--json", ...args], + cwd, + extra + ); + assert( + result.exit === 0 && result.value.ok === true, + "project-aware validation success" + ); + return result.value; +} +function routing(value: Record) { + return record(value.routing_resolution); +} +function record(value: unknown): Record { + assert(isRecord(value), "expected report object"); + return value; +} +async function invoke( + args: readonly string[], + cwd: string, + extra: Readonly> = {} +) { + const child = Bun.spawn([join(bundle, "hack"), ...args], { + cwd, + env: { + PATH: "/usr/bin:/bin", + HOME: home, + HACK_LOGGER: "console", + AWS_SECRET_ACCESS_KEY: canary, + ...extra, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => child.kill("SIGKILL"), 30_000); + try { + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + assert( + !(stdout + stderr).includes(canary), + "private values omitted from output" + ); + return { + stdout, + stderr, + exit, + value: args[0] === "config" ? record(JSON.parse(stdout)) : {}, + }; + } finally { + clearTimeout(timer); + } +} +async function git(args: readonly string[], cwd: string) { + const child = Bun.spawn(["/usr/bin/git", "-C", cwd, ...args], { + env: { + PATH: "/usr/bin:/bin", + HOME: home, + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + }, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exit] = await Promise.all([ + new Response(child.stderr).text(), + child.exited, + ]); + assert(exit === 0, `isolated Git fixture: ${stderr}`); +} +function assert(condition: boolean, label: string): asserts condition { + if (!condition) { + throw new Error(`Native routing CLI acceptance failed: ${label}`); + } + checks += 1; +} diff --git a/src/commands/config-plan.ts b/src/commands/config-plan.ts index bd7d2493c..832ba0166 100644 --- a/src/commands/config-plan.ts +++ b/src/commands/config-plan.ts @@ -20,7 +20,7 @@ const spec = defineCommand({ "Inspect native environment binding completeness without decrypting or starting workloads", group: "Project", description: - "Resolves the native project and permitted local env selection, then inspects managed-env names, winning scopes and secret flags. Managed YAML is parsed by its existing owner; keys and values are never decrypted or returned. Required unresolved refs return a nonzero exit. This experimental report does not establish runtime admission or enable native execution; use config validate for validation without managed-document reads.", + "Resolves the native project, permitted local env selection and routing preview, then inspects managed-env names, winning scopes and secret flags. Managed YAML is parsed by its existing owner; keys and values are never decrypted or returned. Required unresolved refs return a nonzero exit. This experimental report does not establish runtime admission or enable native execution; use config validate for validation without managed-document reads.", options: [ defineOption({ name: "profile", @@ -29,6 +29,13 @@ const spec = defineCommand({ valueHint: "", description: "Comma-separated declared native profiles", } as const), + defineOption({ + name: "domain", + type: "string", + long: "--domain", + valueHint: "", + description: "Select the generated native routing domain suffix", + } as const), optPath, optEnv, optJson, @@ -50,6 +57,7 @@ export const configPlanCommand = withHandler(spec, async ({ ctx, args }) => { const result = await planNativeProject({ startDir: resolve(ctx.cwd, args.options.path ?? "."), profiles, + explicitDomain: args.options.domain, explicitOverlay: args.options.env === "base" ? null : args.options.env, signal: controller.signal, }); @@ -100,6 +108,12 @@ function renderPlan(opts: { process.stdout.write( `Selected env: ${envPlan.overlay ?? "base"} (${result.local_resolution.origin}). This report does not establish runtime admission.\n` ); + if (result.routing_resolution) { + const routing = result.routing_resolution; + process.stdout.write( + `Routing preview: ${routing.open_origin}. Domain: ${routing.domain} (${routing.domain_origin}). DNS and TLS are not checked.\n` + ); + } for (const diagnostic of [...envPlan.warnings, ...envPlan.diagnostics]) { renderDiagnostic(diagnostic); } diff --git a/src/commands/config-validate.ts b/src/commands/config-validate.ts index 6d15742c9..706e13689 100644 --- a/src/commands/config-validate.ts +++ b/src/commands/config-validate.ts @@ -22,7 +22,7 @@ const spec = defineCommand({ "Validate native configuration and selected local overlays without starting workloads", group: "Project", description: - "Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads.", + "Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay and routing settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads.", options: [ defineOption({ name: "file", @@ -39,6 +39,13 @@ const spec = defineCommand({ valueHint: "", description: "Comma-separated declared native profiles", } as const), + defineOption({ + name: "domain", + type: "string", + long: "--domain", + valueHint: "", + description: "Select the generated native routing domain suffix", + } as const), optPath, optEnv, optJson, @@ -56,10 +63,12 @@ export const configValidateCommand = withHandler( } if ( file !== undefined && - (args.options.path !== undefined || args.options.env !== undefined) + (args.options.path !== undefined || + args.options.env !== undefined || + args.options.domain !== undefined) ) { throw new CliUsageError( - "--file validates one document and cannot be combined with --path or --env." + "--file validates one document and cannot be combined with --path or --env or --domain." ); } const profiles = args.options.profile @@ -78,6 +87,7 @@ export const configValidateCommand = withHandler( ? await validateNativeProject({ startDir: resolve(ctx.cwd, args.options.path ?? "."), profiles, + explicitDomain: args.options.domain, explicitOverlay: args.options.env === "base" ? null : args.options.env, signal: controller.signal, @@ -132,6 +142,12 @@ function renderValidationResult(opts: { `Selected env: ${local.overlay ?? "base"} (${local.origin}). Local resolution hash: ${local.resolution_hash}\n` ); } + if ("routing_resolution" in result && result.routing_resolution) { + const routing = result.routing_resolution; + process.stdout.write( + `Routing preview: ${routing.open_origin}. Domain: ${routing.domain} (${routing.domain_origin}). DNS and TLS are not checked.\n` + ); + } } else { for (const diagnostic of result.diagnostics) { process.stderr.write( diff --git a/src/lib/native-config-compiler.ts b/src/lib/native-config-compiler.ts index ccd5f73f1..e82eae711 100644 --- a/src/lib/native-config-compiler.ts +++ b/src/lib/native-config-compiler.ts @@ -14,6 +14,12 @@ import { nativeHostSelectionMatches, parseNativeHostTargets, } from "./native-host-plan-protocol.ts"; +import { + type NativeRoutingResolution, + nativeRoutingPlanIsValid, + nativeRoutingSelectionMatches, + parseNativeRoutingResolution, +} from "./native-routing-plan-protocol.ts"; export const NATIVE_CONFIG_INPUT_LIMIT = 1024 * 1024; const OUTPUT_LIMIT = 8 * 1024 * 1024; @@ -49,6 +55,8 @@ export type NativeLocalResolution = { export type NativeConfigResolveResult = | (Extract & { readonly local_resolution: NativeLocalResolution; + readonly routing_resolution?: NativeRoutingResolution; + readonly routing_inputs_required?: true; }) | Extract; @@ -146,6 +154,7 @@ export async function compileNativeConfig(opts: { readonly requireLocalResolution?: boolean; readonly requireEnvPlanning?: boolean; readonly requireHostPlanning?: boolean; + readonly requireRoutingPlanning?: boolean; }): Promise { if (opts.input.byteLength > NATIVE_CONFIG_INPUT_LIMIT) { throw failure( @@ -159,6 +168,7 @@ export async function compileNativeConfig(opts: { requireLocalResolution: opts.requireLocalResolution, requireEnvPlanning: opts.requireEnvPlanning, requireHostPlanning: opts.requireHostPlanning, + requireRoutingPlanning: opts.requireRoutingPlanning, }); const response = await invokeCompiler({ ...request, @@ -166,6 +176,9 @@ export async function compileNativeConfig(opts: { input: opts.input, }); const result = parseCompileResponse(response); + if (result.ok && hasRouting(result.plan) && !opts.requireRoutingPlanning) { + await checkProtocol({ ...request, requireRoutingPlanning: true }); + } if (opts.requireEnvPlanning && result.ok && !result.declared_workloads) { throw failure( "E_COMPILER_RESPONSE", @@ -181,30 +194,52 @@ export async function resolveNativeConfig(opts: { readonly primaryLocal?: Uint8Array; readonly checkoutLocal?: Uint8Array; readonly explicitOverlay?: string | null; + readonly explicitDomain?: string; + readonly globalDomain?: string; + readonly branch?: string; readonly binary?: string; readonly profiles?: readonly string[]; readonly timeoutMs?: number; readonly signal?: AbortSignal; readonly requireEnvPlanning?: boolean; readonly requireHostPlanning?: boolean; + readonly requireRoutingPlanning?: boolean; + readonly probeRoutingInputs?: boolean; }): Promise { - const input = encodeResolveRequest(opts); + const plainInput = encodeResolveRequest(opts); const request = compilerRequest(opts); - await checkProtocol({ + const capabilities = await checkProtocol({ ...request, requireLocalResolution: true, requireEnvPlanning: opts.requireEnvPlanning, requireHostPlanning: opts.requireHostPlanning, + requireRoutingPlanning: + opts.requireRoutingPlanning || hasRoutingInputs(opts), }); + const routingProbe = + opts.probeRoutingInputs === true && capabilities.routingPlanning; + const input = routingProbe + ? encodeResolveRequest({ ...opts, routingProbe }) + : plainInput; const response = await invokeCompiler({ ...request, args: compileArguments("resolve", opts.profiles), input, }); - const parsed = parseResolveResponse(response); + const parsed = parseResolveResponse({ + ...response, + requireRouting: opts.requireRoutingPlanning || hasRoutingInputs(opts), + branch: opts.branch, + explicitDomain: opts.explicitDomain, + globalDomain: opts.globalDomain, + routingProbe, + }); if (!parsed.ok) { return parsed; } + if (parsed.routing_resolution || parsed.routing_inputs_required) { + await checkProtocol({ ...request, requireRoutingPlanning: true }); + } if (opts.requireEnvPlanning && !parsed.declared_workloads) { throw failure( "E_COMPILER_RESPONSE", @@ -221,13 +256,29 @@ type NativeResolveInputs = { readonly primaryLocal?: Uint8Array; readonly checkoutLocal?: Uint8Array; readonly explicitOverlay?: string | null; + readonly explicitDomain?: string; + readonly globalDomain?: string; + readonly branch?: string; }; function encodeResolveRequest( opts: NativeResolveInputs & { readonly envMetadata?: NativeEnvMetadata; + readonly routingProbe?: boolean; } ): Uint8Array { + for (const value of [opts.explicitDomain, opts.globalDomain, opts.branch]) { + if ( + value !== undefined && + (typeof value !== "string" || + Buffer.byteLength(value, "utf8") > NATIVE_CONFIG_INPUT_LIMIT) + ) { + throw failure( + "E_CONFIG_INPUT", + "Native routing selection is invalid or exceeds its budget." + ); + } + } if ( typeof opts.explicitOverlay === "string" && Buffer.byteLength(opts.explicitOverlay, "utf8") > NATIVE_CONFIG_INPUT_LIMIT @@ -250,7 +301,11 @@ function encodeResolveRequest( ? undefined : documentText(opts.checkoutLocal, "checkout_local"), explicit_overlay: opts.explicitOverlay, + explicit_domain: opts.explicitDomain, + global_domain: opts.globalDomain, + branch: opts.branch, env_metadata: opts.envMetadata, + routing_probe: opts.routingProbe ? true : undefined, }) ); if (input.byteLength > RESOLVE_REQUEST_LIMIT) { @@ -270,6 +325,7 @@ export async function planNativeConfig( readonly profiles?: readonly string[]; readonly timeoutMs?: number; readonly signal?: AbortSignal; + readonly requireRoutingPlanning?: boolean; } ): Promise { const metadata = parseNativeEnvMetadata(opts.envMetadata); @@ -290,6 +346,8 @@ export async function planNativeConfig( requireLocalResolution: true, requireEnvPlanning: true, requireHostPlanning: metadata.host !== undefined, + requireRoutingPlanning: + opts.requireRoutingPlanning || hasRoutingInputs(opts), }); const response = await invokeCompiler({ ...request, @@ -299,10 +357,17 @@ export async function planNativeConfig( const parsed = parseResolveResponse({ ...response, allowIncompletePlan: true, + requireRouting: opts.requireRoutingPlanning || hasRoutingInputs(opts), + branch: opts.branch, + explicitDomain: opts.explicitDomain, + globalDomain: opts.globalDomain, }); if (!parsed.ok) { return parsed; } + if (parsed.routing_resolution) { + await checkProtocol({ ...request, requireRoutingPlanning: true }); + } const environmentPlan = parseNativeEnvironmentPlan({ value: parsed.envelope.environment_plan, parseDiagnostic, @@ -373,6 +438,11 @@ function parseResolveResponse(opts: { readonly output: Uint8Array; readonly exitCode: number; readonly allowIncompletePlan?: boolean; + readonly requireRouting?: boolean; + readonly branch?: string; + readonly explicitDomain?: string; + readonly globalDomain?: string; + readonly routingProbe?: boolean; }): | (Extract & { readonly envelope: Readonly>; @@ -401,9 +471,53 @@ function parseResolveResponse(opts: { "Native local resolution returned an invalid envelope." ); } + const routing = + envelope.routing_resolution === undefined + ? undefined + : parseNativeRoutingResolution(envelope.routing_resolution); + const routingInputsRequired = envelope.routing_inputs_required; + if ( + (routingInputsRequired !== undefined && + !(opts.routingProbe && routingInputsRequired === true)) || + (opts.routingProbe && routing !== undefined) || + (opts.routingProbe && + (opts.requireRouting || + Object.hasOwn(result.plan, "routes") || + Object.hasOwn(result.plan, "open")) && + routingInputsRequired !== true) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native routing input probe returned an invalid result." + ); + } + if ( + routing === null || + !( + opts.routingProbe || + nativeRoutingSelectionMatches({ + plan: result.plan, + declared: result.declared_workloads, + resolution: routing, + required: opts.requireRouting, + branch: opts.branch, + explicitDomain: opts.explicitDomain, + globalDomain: opts.globalDomain, + }) + ) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native routing resolution returned an invalid result." + ); + } return { ...result, local_resolution: parseLocalResolution(envelope.local_resolution), + ...(routing === undefined ? {} : { routing_resolution: routing }), + ...(routingInputsRequired === true + ? { routing_inputs_required: true as const } + : {}), envelope, }; } @@ -456,7 +570,8 @@ async function checkProtocol(opts: { readonly requireLocalResolution?: boolean; readonly requireEnvPlanning?: boolean; readonly requireHostPlanning?: boolean; -}): Promise { + readonly requireRoutingPlanning?: boolean; +}): Promise<{ readonly routingPlanning: boolean }> { const handshake = await invokeCompiler({ ...opts, args: ["--protocol"] }); const protocol = parseControlJson(handshake.output); if ( @@ -467,6 +582,7 @@ async function checkProtocol(opts: { protocol.plan_version !== 1 || (opts.requireEnvPlanning && protocol.env_plan_version !== 1) || (opts.requireHostPlanning && protocol.host_env_plan_version !== 1) || + (opts.requireRoutingPlanning && protocol.routing_plan_version !== 1) || (opts.requireLocalResolution && (protocol.resolve_version !== 1 || protocol.local_version !== 1)) ) { @@ -475,6 +591,7 @@ async function checkProtocol(opts: { "Native configuration compiler version mismatch." ); } + return { routingPlanning: protocol.routing_plan_version === 1 }; } function compileArguments( @@ -698,8 +815,19 @@ function parseCompileResponse(opts: { readonly output: Uint8Array; readonly exitCode: number; }): NativeConfigCompileResult { + const value = parseControlJson(opts.output); + if ( + isRecord(value) && + (Object.hasOwn(value, "routing_resolution") || + Object.hasOwn(value, "routing_inputs_required")) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native compilation returned a context-dependent routing report." + ); + } return parseCompileValue({ - value: parseControlJson(opts.output), + value, exitCode: opts.exitCode, }); } @@ -740,6 +868,12 @@ function parseCompileValue(opts: { plan: value.plan, declared, }); + if (!nativeRoutingPlanIsValid({ plan: value.plan, declared })) { + throw failure( + "E_COMPILER_RESPONSE", + "Native compiler returned invalid routing declarations." + ); + } return { transport_version: 1, ok: true, @@ -764,6 +898,18 @@ function parseCompileValue(opts: { ); } +function hasRouting(plan: Readonly>): boolean { + return plan.routes !== undefined || plan.open !== undefined; +} + +function hasRoutingInputs(opts: NativeResolveInputs): boolean { + return ( + opts.explicitDomain !== undefined || + opts.globalDomain !== undefined || + opts.branch !== undefined + ); +} + function parseHostNamespace(opts: { readonly value: Record; readonly plan: Readonly>; diff --git a/src/lib/native-project-validation.ts b/src/lib/native-project-validation.ts index e48b3db5e..348073f79 100644 --- a/src/lib/native-project-validation.ts +++ b/src/lib/native-project-validation.ts @@ -11,12 +11,15 @@ import { acquireNativeLocalInputs, acquireNativeProjectInput, } from "./native-project-inputs.ts"; +import { acquireNativeGlobalDomain } from "./native-routing-inputs.ts"; import { resolveProjectEnvMetadataForNativeSelection } from "./project-env-config.ts"; +import { resolveVerifiedNativeBranch } from "./worktree-local-config.ts"; type NativeProjectSelection = { readonly startDir: string; readonly profiles?: readonly string[]; readonly explicitOverlay?: string | null; + readonly explicitDomain?: string; readonly signal?: AbortSignal; }; @@ -78,6 +81,7 @@ export async function planNativeProject( ...opts, input: prepared.input, ...prepared.locals, + ...prepared.routingInputs, envMetadata: { metadata_version: 1, overlay: metadata.overlay, @@ -98,7 +102,9 @@ export async function planNativeProject( result.ok && (result.semantic_hash !== resolved.semantic_hash || result.local_resolution.resolution_hash !== - resolved.local_resolution.resolution_hash) + resolved.local_resolution.resolution_hash || + JSON.stringify(result.routing_resolution) !== + JSON.stringify(resolved.routing_resolution)) ) { throw new NativeConfigCompilerError( "E_COMPILER_RESPONSE", @@ -123,6 +129,7 @@ async function prepareNativeProject( signal: opts.signal, requireLocalResolution: true, requireEnvPlanning: opts.requireEnvPlanning, + requireRoutingPlanning: opts.explicitDomain !== undefined, }); if (!compiled.ok) { const result: NativeConfigResolveResult = { @@ -132,7 +139,7 @@ async function prepareNativeProject( document: "project" as const, })), }; - return { ...project, result, locals: {} }; + return { ...project, result, locals: {}, routingInputs: {} }; } const worktree = compiled.plan.worktree; if (!isRecord(worktree) || typeof worktree.inherit_local !== "boolean") { @@ -146,26 +153,58 @@ async function prepareNativeProject( inheritLocal: worktree.inherit_local, signal: opts.signal, }); - const result = await resolveNativeConfig({ + const resolveInputs = { input: project.input, ...locals, profiles: opts.profiles, explicitOverlay: opts.explicitOverlay, + explicitDomain: opts.explicitDomain, signal: opts.signal, requireEnvPlanning: opts.requireEnvPlanning, requireHostPlanning: opts.requireEnvPlanning && compiled.host_env_targets !== undefined, + requireRoutingPlanning: + opts.explicitDomain !== undefined || + Object.hasOwn(compiled.plan, "routes") || + Object.hasOwn(compiled.plan, "open"), + }; + const checkResolvedIdentity = (result: NativeConfigResolveResult) => { + if ( + result.ok && + (result.semantic_hash !== compiled.semantic_hash || + JSON.stringify(result.host_env_targets) !== + JSON.stringify(compiled.host_env_targets)) + ) { + throw new NativeConfigCompilerError( + "E_COMPILER_RESPONSE", + "Native local resolution changed the authored identity or host targets." + ); + } + }; + let result = await resolveNativeConfig({ + ...resolveInputs, + probeRoutingInputs: true, }); - if ( - result.ok && - (result.semantic_hash !== compiled.semantic_hash || - JSON.stringify(result.host_env_targets) !== - JSON.stringify(compiled.host_env_targets)) - ) { - throw new NativeConfigCompilerError( - "E_COMPILER_RESPONSE", - "Native local resolution changed the authored identity or host targets." - ); + checkResolvedIdentity(result); + let routingInputs: { + readonly globalDomain?: string; + readonly branch?: string; + } = {}; + if (result.ok && result.routing_inputs_required === true) { + routingInputs = { + globalDomain: await acquireNativeGlobalDomain({ signal: opts.signal }), + branch: await resolveVerifiedNativeBranch({ + projectRoot: project.projectRoot, + autoBranch: result.local_resolution.auto_branch, + signal: opts.signal, + }), + }; + result = await resolveNativeConfig({ + ...resolveInputs, + ...routingInputs, + requireRoutingPlanning: true, + }); } - return { ...project, result, locals }; + checkResolvedIdentity(result); + return { ...project, result, locals, routingInputs }; } diff --git a/src/lib/native-routing-inputs.ts b/src/lib/native-routing-inputs.ts new file mode 100644 index 000000000..2a79f243c --- /dev/null +++ b/src/lib/native-routing-inputs.ts @@ -0,0 +1,164 @@ +import { constants, type Stats } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { resolveGlobalConfigPath } from "./config-paths.ts"; +import { isRecord } from "./guards.ts"; +import { + NATIVE_CONFIG_INPUT_LIMIT, + NativeConfigCompilerError, +} from "./native-config-compiler.ts"; + +function invalidPolicy(): NativeConfigCompilerError { + return new NativeConfigCompilerError( + "E_CONFIG_INPUT", + "Cannot read native routing global policy: expected a stable bounded regular JSON object with a string default_domain; values omitted." + ); +} +function cancelled(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new NativeConfigCompilerError( + "E_COMPILER_CANCELLED", + "Native routing policy acquisition was cancelled." + ); + } +} +function same(before: Stats, after: Stats): boolean { + return ( + before.dev === after.dev && + before.ino === after.ino && + before.size === after.size && + before.mtimeMs === after.mtimeMs && + before.ctimeMs === after.ctimeMs + ); +} +async function optionalStat(path: string): Promise { + return await lstat(path).catch((error: unknown) => { + if (isRecord(error) && error.code === "ENOENT") { + return null; + } + throw invalidPolicy(); + }); +} +async function directories( + path: string +): Promise { + const result: { path: string; info: Stats | null }[] = []; + let current = dirname(path); + while (true) { + const info = await optionalStat(current); + if ( + info && + (!info.isDirectory() || (await realpath(current)) !== current) + ) { + throw invalidPolicy(); + } + result.push({ path: current, info }); + const parent = dirname(current); + if (parent === current) { + return result; + } + current = parent; + } +} +async function recheckDirectories( + entries: Awaited> +): Promise { + for (const entry of entries) { + const now = await optionalStat(entry.path); + if ( + entry.info === null + ? now !== null + : !now?.isDirectory() || + now.dev !== entry.info.dev || + now.ino !== entry.info.ino || + (await realpath(entry.path)) !== entry.path + ) { + throw invalidPolicy(); + } + } +} + +/** Read only the routing scalar from global policy; no defaulting, domain validation, keys or writes. */ +export async function acquireNativeGlobalDomain( + opts: { readonly signal?: AbortSignal } = {} +): Promise { + try { + cancelled(opts.signal); + const path = resolve(resolveGlobalConfigPath()); + const parents = await directories(path); + const observed = await optionalStat(path); + if (!observed) { + await recheckDirectories(parents); + cancelled(opts.signal); + return undefined; + } + if ( + !observed.isFile() || + observed.size > NATIVE_CONFIG_INPUT_LIMIT || + (observed.mode & 0o444) === 0 || + (await realpath(path)) !== path + ) { + throw invalidPolicy(); + } + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + const before = await file.stat(); + if (!(before.isFile() && same(observed, before))) { + throw invalidPolicy(); + } + const buffer = Buffer.alloc(before.size + 1); + let size = 0; + while (size < buffer.length) { + cancelled(opts.signal); + const read = await file.read(buffer, size, buffer.length - size, size); + if (read.bytesRead === 0) { + break; + } + size += read.bytesRead; + } + const after = await file.stat(); + const current = await optionalStat(path); + if ( + size !== before.size || + !current?.isFile() || + !same(before, after) || + !same(before, current) || + (await realpath(path)) !== path + ) { + throw invalidPolicy(); + } + await recheckDirectories(parents); + cancelled(opts.signal); + const parsed: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode( + buffer.subarray(0, size) + ) + ); + if ( + !isRecord(parsed) || + (Object.hasOwn(parsed, "default_domain") && + typeof parsed.default_domain !== "string") + ) { + throw invalidPolicy(); + } + const domain = parsed.default_domain; + return Object.hasOwn(parsed, "default_domain") && + typeof domain === "string" + ? domain + : undefined; + } finally { + await file.close(); + } + } catch (error: unknown) { + if ( + error instanceof NativeConfigCompilerError && + error.code === "E_COMPILER_CANCELLED" + ) { + throw error; + } + throw invalidPolicy(); + } +} diff --git a/src/lib/native-routing-plan-protocol.ts b/src/lib/native-routing-plan-protocol.ts new file mode 100644 index 000000000..24219c457 --- /dev/null +++ b/src/lib/native-routing-plan-protocol.ts @@ -0,0 +1,546 @@ +import { isRecord } from "./guards.ts"; +import type { NativeDeclaredWorkloads } from "./native-env-plan-protocol.ts"; + +const NAME = /^[a-z0-9][a-z0-9_.-]{0,62}$/; +const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; +const NUMERIC_LABEL = /^(?:\d+|0x[0-9a-f]*)$/; +const IPV4 = /^\d+\.\d+\.\d+\.\d+$/; + +type OpenPreference = "auto" | "alias" | "dev"; +type Protocol = "http" | "https"; +export type NativeResolvedRoute = { + readonly service: string; + readonly port: number; + readonly protocol: Protocol; + readonly origin: string; + readonly aliases: Readonly>; +}; + +/** Public origins are a preview, never evidence of DNS, trust, or OAuth acceptance. */ +export type NativeRoutingResolution = { + readonly domain: string; + readonly domain_origin: + | "default" + | "global" + | "project" + | "primary_local" + | "checkout_local" + | "explicit"; + readonly project_origin: string; + readonly aliases: Readonly>; + readonly oauth_alias: string | null; + readonly open_preference: OpenPreference; + readonly open_preference_origin: + | "default" + | "project" + | "primary_local" + | "checkout_local"; + readonly open_origin: string; + readonly routes: Readonly>; + readonly branch?: string; +}; + +function keys( + value: Record, + allowed: readonly string[] +): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} + +function domain(value: unknown): value is string { + if (typeof value !== "string" || value.length > 253) { + return false; + } + const labels = value.split("."); + return ( + (labels.length >= 2 || value === "hack") && + labels.every((label) => DNS_LABEL.test(label)) && + !NUMERIC_LABEL.test(labels.at(-1) ?? "") + ); +} + +function relativeHost(value: unknown): value is string { + return ( + typeof value === "string" && + value.length <= 253 && + value.split(".").every((label) => DNS_LABEL.test(label)) + ); +} + +/** Check the compiler's canonical origin encoding without resolving a hostname. */ +function origin(value: unknown): value is string { + if (typeof value !== "string" || value.length > 2048) { + return false; + } + try { + const parsed = new URL(value); + const host = parsed.hostname; + return ( + (parsed.protocol === "http:" || parsed.protocol === "https:") && + parsed.origin === value && + parsed.username === "" && + parsed.password === "" && + parsed.pathname === "/" && + parsed.search === "" && + parsed.hash === "" && + (host.startsWith("[") || relativeHost(host)) + ); + } catch { + return false; + } +} + +function preference(value: unknown): value is OpenPreference { + return value === "auto" || value === "alias" || value === "dev"; +} + +function domainOrigin( + value: unknown +): value is NativeRoutingResolution["domain_origin"] { + return ( + value === "default" || + value === "global" || + value === "project" || + value === "primary_local" || + value === "checkout_local" || + value === "explicit" + ); +} + +function openPreferenceOrigin( + value: unknown +): value is NativeRoutingResolution["open_preference_origin"] { + return ( + value === "default" || + value === "project" || + value === "primary_local" || + value === "checkout_local" + ); +} + +function protocol(value: unknown): value is Protocol { + return value === "http" || value === "https"; +} + +function port(value: unknown): value is number { + return ( + typeof value === "number" && + Number.isSafeInteger(value) && + value >= 1 && + value <= 65_535 + ); +} + +function origins(value: unknown): Readonly> | null { + if (!isRecord(value)) { + return null; + } + const entries: [string, string][] = []; + for (const [name, item] of Object.entries(value)) { + if (!(NAME.test(name) && origin(item))) { + return null; + } + entries.push([name, item]); + } + return Object.fromEntries(entries); +} + +/** Validate additive portable declarations; Rust still owns authored validation and selection. */ +export function nativeRoutingPlanIsValid(opts: { + readonly plan: Readonly>; + readonly declared?: NativeDeclaredWorkloads; +}): boolean { + const routes = opts.plan.routes; + const open = opts.plan.open; + if ( + open !== undefined && + !(isRecord(open) && keys(open, ["prefer"]) && preference(open.prefer)) + ) { + return false; + } + if (routes === undefined) { + return true; + } + if ( + !( + isRecord(routes) && + keys(routes, ["domain", "origin", "aliases", "oauth_alias", "http"]) && + isRecord(routes.aliases) && + isRecord(routes.http) + ) + ) { + return false; + } + if ( + (routes.domain !== undefined && !domain(routes.domain)) || + (routes.origin !== undefined && !origin(routes.origin)) + ) { + return false; + } + return ( + aliasDeclarationsValid(routes.aliases) && + (routes.oauth_alias === undefined || + (typeof routes.oauth_alias === "string" && + Object.hasOwn(routes.aliases, routes.oauth_alias))) && + routeDeclarationsValid(routes.http, opts.declared) + ); +} + +function aliasDeclarationsValid(aliases: Record): boolean { + for (const [name, alias] of Object.entries(aliases)) { + if (!(NAME.test(name) && isRecord(alias))) { + return false; + } + const fields = Object.keys(alias); + if ( + fields.length !== 1 || + !(fields[0] === "domain" + ? domain(alias.domain) + : fields[0] === "origin" && origin(alias.origin)) + ) { + return false; + } + } + return true; +} + +function routeDeclarationsValid( + routes: Record, + declared: NativeDeclaredWorkloads | undefined +): boolean { + for (const [name, route] of Object.entries(routes)) { + if ( + !( + NAME.test(name) && + isRecord(route) && + keys(route, ["service", "port", "protocol", "hostname"]) && + typeof route.service === "string" && + declared?.[route.service] === "service" && + port(route.port) && + protocol(route.protocol) && + relativeHost(route.hostname) + ) + ) { + return false; + } + } + return true; +} + +export function parseNativeRoutingResolution( + value: unknown +): NativeRoutingResolution | null { + if ( + !( + isRecord(value) && + keys(value, [ + "domain", + "domain_origin", + "project_origin", + "aliases", + "oauth_alias", + "open_preference", + "open_preference_origin", + "open_origin", + "routes", + "branch", + ]) && + domain(value.domain) && + domainOrigin(value.domain_origin) && + origin(value.project_origin) && + preference(value.open_preference) && + openPreferenceOrigin(value.open_preference_origin) && + origin(value.open_origin) && + isRecord(value.routes) + ) + ) { + return null; + } + if ( + value.branch !== undefined && + !(typeof value.branch === "string" && DNS_LABEL.test(value.branch)) + ) { + return null; + } + const aliases = origins(value.aliases); + if ( + !( + aliases && + (value.oauth_alias === null || + (typeof value.oauth_alias === "string" && + Object.hasOwn(aliases, value.oauth_alias))) + ) + ) { + return null; + } + if ( + new Set([value.project_origin, ...Object.values(aliases)]).size !== + Object.keys(aliases).length + 1 + ) { + return null; + } + const entries: [string, NativeResolvedRoute][] = []; + for (const [name, route] of Object.entries(value.routes)) { + if ( + !( + NAME.test(name) && + isRecord(route) && + keys(route, ["service", "port", "protocol", "origin", "aliases"]) && + typeof route.service === "string" && + NAME.test(route.service) && + port(route.port) && + protocol(route.protocol) && + origin(route.origin) + ) + ) { + return null; + } + const routeAliases = origins(route.aliases); + if (!routeAliases) { + return null; + } + entries.push([ + name, + { + service: route.service, + port: route.port, + protocol: route.protocol, + origin: route.origin, + aliases: routeAliases, + }, + ]); + } + return { + domain: value.domain, + domain_origin: value.domain_origin, + project_origin: value.project_origin, + aliases, + oauth_alias: value.oauth_alias, + open_preference: value.open_preference, + open_preference_origin: value.open_preference_origin, + open_origin: value.open_origin, + routes: Object.fromEntries(entries), + ...(typeof value.branch === "string" ? { branch: value.branch } : {}), + }; +} + +function sameNames( + left: Record, + right: Readonly> +): boolean { + const names = Object.keys(left); + return ( + names.length === Object.keys(right).length && + names.every((name) => Object.hasOwn(right, name)) + ); +} + +/** Cross-check declaration/selection cardinality so stale or foreign routes cannot enter the report. */ +export function nativeRoutingSelectionMatches(opts: { + readonly plan: Readonly>; + readonly declared?: NativeDeclaredWorkloads; + readonly resolution?: NativeRoutingResolution; + readonly required?: boolean; + readonly branch?: string; + readonly explicitDomain?: string; + readonly globalDomain?: string; +}): boolean { + if (!nativeRoutingPlanIsValid(opts)) { + return false; + } + const resolution = opts.resolution; + if (!resolution) { + return ( + !opts.required && + opts.plan.routes === undefined && + opts.plan.open === undefined + ); + } + if ( + resolution.branch !== opts.branch || + (opts.explicitDomain !== undefined && + (resolution.domain !== opts.explicitDomain || + resolution.domain_origin !== "explicit")) || + (resolution.domain_origin === "explicit" && + opts.explicitDomain === undefined) || + (resolution.domain_origin === "global" && + resolution.domain !== opts.globalDomain) || + (resolution.domain_origin === "default" && + resolution.domain !== "hack.local") + ) { + return false; + } + const routes = opts.plan.routes; + const declaredRoutes = + isRecord(routes) && isRecord(routes.http) ? routes.http : {}; + const aliases = + isRecord(routes) && isRecord(routes.aliases) ? routes.aliases : {}; + const selected = opts.plan.services; + if (!(isRecord(selected) && sameNames(aliases, resolution.aliases))) { + return false; + } + return ( + selectedRoutesMatch({ declaredRoutes, aliases, selected, resolution }) && + routingOriginsMatch({ + routes, + aliases, + resolution, + projectName: opts.plan.name, + }) && + openSelectionMatches(opts.plan.open, resolution) + ); +} + +function selectedRoutesMatch(opts: { + readonly declaredRoutes: Record; + readonly aliases: Record; + readonly selected: Record; + readonly resolution: NativeRoutingResolution; +}): boolean { + const { declaredRoutes, aliases, selected, resolution } = opts; + const expected = Object.entries(declaredRoutes).filter( + ([, route]) => + isRecord(route) && + typeof route.service === "string" && + Object.hasOwn(selected, route.service) + ); + if (expected.length !== Object.keys(resolution.routes).length) { + return false; + } + const seen = new Set(); + for (const [name, route] of expected) { + const item = resolution.routes[name]; + if ( + !(isRecord(route) && item) || + item.service !== route.service || + item.port !== route.port || + item.protocol !== route.protocol || + !sameNames(aliases, item.aliases) || + !scopedOriginsMatch({ route, item, resolution, seen }) + ) { + return false; + } + } + return true; +} + +function scopedOriginsMatch(opts: { + readonly route: Record; + readonly item: NativeResolvedRoute; + readonly resolution: NativeRoutingResolution; + readonly seen: Set; +}): boolean { + if ( + typeof opts.route.hostname !== "string" || + opts.item.origin !== + scopedOrigin(opts.route.hostname, opts.resolution.project_origin) + ) { + return false; + } + for (const [name, base] of Object.entries(opts.resolution.aliases)) { + if (opts.item.aliases[name] !== scopedOrigin(opts.route.hostname, base)) { + return false; + } + } + for (const value of [opts.item.origin, ...Object.values(opts.item.aliases)]) { + if (opts.seen.has(value)) { + return false; + } + opts.seen.add(value); + } + return true; +} + +/** Verify the fixed wire relation only; Rust owns route admission and domain precedence. */ +function scopedOrigin(hostname: string, base: string): string | null { + if (hostname === "project") { + return base; + } + const parsed = new URL(base); + if (parsed.hostname.startsWith("[") || IPV4.test(parsed.hostname)) { + return null; + } + const result = `${parsed.protocol}//${hostname}.${parsed.host}`; + return origin(result) ? result : null; +} + +function routingOriginsMatch(opts: { + readonly routes: unknown; + readonly aliases: Record; + readonly resolution: NativeRoutingResolution; + readonly projectName: unknown; +}): boolean { + const { routes, aliases, resolution } = opts; + const oauthAlias = isRecord(routes) ? (routes.oauth_alias ?? null) : null; + if ( + oauthAlias !== resolution.oauth_alias || + (isRecord(routes) && + routes.origin !== undefined && + routes.origin !== resolution.project_origin) + ) { + return false; + } + if (typeof opts.projectName !== "string") { + return false; + } + const namespace = `${resolution.branch === undefined ? "" : `${resolution.branch}.`}${opts.projectName}`; + if ( + (!isRecord(routes) || routes.origin === undefined) && + resolution.project_origin !== `https://${namespace}.${resolution.domain}` + ) { + return false; + } + if ( + resolution.domain_origin === "project" && + (!isRecord(routes) || routes.domain !== resolution.domain) + ) { + return false; + } + for (const [name, alias] of Object.entries(aliases)) { + if ( + isRecord(alias) && + alias.origin !== undefined && + resolution.aliases[name] !== alias.origin + ) { + return false; + } + if ( + isRecord(alias) && + typeof alias.domain === "string" && + resolution.aliases[name] !== `https://${namespace}.${alias.domain}` + ) { + return false; + } + } + return true; +} + +function openSelectionMatches( + open: unknown, + resolution: NativeRoutingResolution +): boolean { + if ( + resolution.open_preference_origin === "project" && + (!isRecord(open) || open.prefer !== resolution.open_preference) + ) { + return false; + } + if ( + resolution.open_preference_origin === "default" && + resolution.open_preference !== "auto" + ) { + return false; + } + const aliasOrigin = + resolution.oauth_alias === null + ? undefined + : resolution.aliases[resolution.oauth_alias]; + const expectedOpen = + resolution.open_preference === "dev" + ? resolution.project_origin + : (aliasOrigin ?? resolution.project_origin); + return ( + !(resolution.open_preference === "alias" && aliasOrigin === undefined) && + resolution.open_origin === expectedOpen + ); +} diff --git a/src/lib/worktree-local-config.ts b/src/lib/worktree-local-config.ts index 7b9534e05..6cc9e9525 100644 --- a/src/lib/worktree-local-config.ts +++ b/src/lib/worktree-local-config.ts @@ -8,7 +8,7 @@ import { } from "./git-worktree.ts"; import { isRecord } from "./guards.ts"; import { NativeConfigCompilerError } from "./native-config-compiler.ts"; -import { readProjectConfig } from "./project.ts"; +import { readProjectConfig, sanitizeBranchSlug } from "./project.ts"; /** Keep runner exclusions independent of either configuration format's policy parser. */ export function shouldInheritPrimaryLocalInputs(opts: { @@ -84,6 +84,126 @@ export async function resolveVerifiedPrimaryWorktreeRoot(opts: { } } +/** Derive a read-only linked-worktree namespace under the existing collision convention. */ +export async function resolveVerifiedNativeBranch(opts: { + readonly projectRoot: string; + readonly autoBranch: boolean; + readonly signal?: AbortSignal; +}): Promise { + try { + throwIfCancelled(opts.signal); + if (!shouldInheritPrimaryLocalInputs({ inheritLocal: opts.autoBranch })) { + return undefined; + } + const projectRoot = resolve(opts.projectRoot); + if ((await realpath(projectRoot)) !== projectRoot) { + throw worktreeVerificationError(); + } + const primary = await resolveVerifiedPrimaryWorktreeRoot({ + projectRoot, + signal: opts.signal, + }); + if (primary === null) { + return undefined; + } + const before = await readGitCheckoutIdentity({ + projectRoot, + signal: opts.signal, + }); + if (!before) { + throw worktreeVerificationError(); + } + const admin = await lstat(before.gitDir); + const common = await lstat(before.commonDir); + const marker = await lstat(resolve(projectRoot, ".git")); + const read = (args: readonly string[]) => + readGitInspection({ projectRoot, args, signal: opts.signal }); + const branch = await read(["symbolic-ref", "--quiet", "HEAD"]); + const listing = await read(["worktree", "list", "--porcelain", "-z"]); + if ( + !(branch?.startsWith("refs/heads/") && branch.endsWith("\n")) || + branch.trimEnd().includes("\n") || + listing === null + ) { + throw worktreeVerificationError(); + } + const ref = branch.trimEnd(); + const entries = listing + .split("\0\0") + .filter(Boolean) + .map((entry) => { + const fields = entry.split("\0"); + const roots = fields.filter((field) => field.startsWith("worktree ")); + const branches = fields.filter((field) => field.startsWith("branch ")); + if (roots.length !== 1 || branches.length > 1) { + throw worktreeVerificationError(); + } + return { root: roots[0]?.slice(9), branch: branches[0]?.slice(7) }; + }); + const selected = entries.filter((entry) => entry.root === projectRoot); + if (selected.length !== 1 || selected[0]?.branch !== ref) { + throw worktreeVerificationError(); + } + const names = entries.flatMap((entry) => + entry.branch?.startsWith("refs/heads/") ? [entry.branch.slice(11)] : [] + ); + const raw = ref.slice(11); + const derive = (name: string) => { + const slug = sanitizeBranchSlug(name); + const collision = names.some( + (other) => other !== name && sanitizeBranchSlug(other) === slug + ); + return collision + ? `${slug}-${new Bun.CryptoHasher("sha1").update(name).digest("hex").slice(0, 4)}` + : slug; + }; + const slug = derive(raw); + if ( + !NATIVE_BRANCH_DNS_LABEL.test(slug) || + slug.length > 63 || + names.some((other) => other !== raw && derive(other) === slug) + ) { + throw worktreeVerificationError(); + } + const after = await readGitCheckoutIdentity({ + projectRoot, + signal: opts.signal, + }); + const markerAfter = await lstat(resolve(projectRoot, ".git")); + const adminAfter = await lstat(before.gitDir); + const commonAfter = await lstat(before.commonDir); + if ( + !(before && after) || + before.gitDir !== after.gitDir || + before.commonDir !== after.commonDir || + admin.dev !== adminAfter.dev || + admin.ino !== adminAfter.ino || + common.dev !== commonAfter.dev || + common.ino !== commonAfter.ino || + marker.dev !== markerAfter.dev || + marker.ino !== markerAfter.ino || + marker.ctimeMs !== markerAfter.ctimeMs || + (await read(["symbolic-ref", "--quiet", "HEAD"])) !== branch || + (await read(["worktree", "list", "--porcelain", "-z"])) !== listing || + (await resolveVerifiedPrimaryWorktreeRoot({ + projectRoot, + signal: opts.signal, + })) !== primary + ) { + throw worktreeVerificationError(); + } + throwIfCancelled(opts.signal); + return slug; + } catch (error: unknown) { + if (error instanceof NativeConfigCompilerError) { + throw error; + } + throw worktreeVerificationError(); + } +} + +const NATIVE_BRANCH_DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; + type GitCheckoutIdentity = { readonly gitDir: string; readonly commonDir: string; diff --git a/tests/helpers/scoped-module-mock.ts b/tests/helpers/scoped-module-mock.ts index 16f73372f..8d3c709ae 100644 --- a/tests/helpers/scoped-module-mock.ts +++ b/tests/helpers/scoped-module-mock.ts @@ -17,7 +17,36 @@ interface ModuleMockState { readonly wrappers: Map; } -const statesByResolvedPath = new Map(); +const statesByResolvedPath = new Map>(); + +/** + * Capture once, including concurrent registrations. Overrides that delegate to a + * real implementation must use this snapshot rather than reimporting a live + * module namespace that another test file may already have mocked. + */ +function moduleState(resolvedPath: string): Promise { + const existing = statesByResolvedPath.get(resolvedPath); + if (existing) { + return existing; + } + const pending = import(resolvedPath).then((namespace: ModuleExports) => ({ + real: captureExportValues(namespace), + registrations: [], + wrappers: new Map(), + })); + statesByResolvedPath.set(resolvedPath, pending); + return pending; +} + +/** Obtain the captured builtin implementation, bypassing all scoped dispatch wrappers. */ +export async function readUnmockedFsPromisesExport< + Key extends keyof typeof import("node:fs/promises"), +>(key: Key): Promise { + const state = await moduleState("node:fs/promises"); + // The snapshot came from this exact builtin; its static export signature is + // retained here, not inferred from the possibly patched live namespace. + return state.real[key] as typeof import("node:fs/promises")[Key]; +} export interface ScopedModuleMock { /** Enables this file's overrides. Call from beforeAll in the owning test file. */ @@ -53,16 +82,7 @@ export async function registerScopedModuleMock(opts: { ? opts.specifier : Bun.resolveSync(opts.specifier, dirname(opts.importerPath)); - let state = statesByResolvedPath.get(resolvedPath); - if (!state) { - const namespace = (await import(resolvedPath)) as ModuleExports; - state = { - real: captureExportValues(namespace), - registrations: [], - wrappers: new Map(), - }; - statesByResolvedPath.set(resolvedPath, state); - } + const state = await moduleState(resolvedPath); const registration: OverrideRegistration = { overrides: opts.overrides, diff --git a/tests/native-project-inputs-acquisition-race.test.ts b/tests/native-project-inputs-acquisition-race.test.ts index b32fb63b7..893be6796 100644 --- a/tests/native-project-inputs-acquisition-race.test.ts +++ b/tests/native-project-inputs-acquisition-race.test.ts @@ -17,14 +17,17 @@ import { import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { HackCliError } from "../src/lib/cli-result.ts"; -import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; +import { + readUnmockedFsPromisesExport, + registerScopedModuleMock, +} from "./helpers/scoped-module-mock.ts"; type AcquisitionChange = "replace" | "rewrite" | "parent" | "denied"; let root: string; let targetFile: string | null = null; let change: AcquisitionChange = "replace"; let closedFiles = 0; -const { open: realOpen } = await import("node:fs/promises"); +const realOpen = await readUnmockedFsPromisesExport("open"); const fileMock = await registerScopedModuleMock({ importerPath: import.meta.path, diff --git a/tests/native-routing-input-race.test.ts b/tests/native-routing-input-race.test.ts new file mode 100644 index 000000000..b37ff4fe8 --- /dev/null +++ b/tests/native-routing-input-race.test.ts @@ -0,0 +1,125 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { + mkdir, + mkdtemp, + realpath, + rename, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { restoreEnv } from "./helpers/env.ts"; +import { + readUnmockedFsPromisesExport, + registerScopedModuleMock, +} from "./helpers/scoped-module-mock.ts"; + +const realOpen = await readUnmockedFsPromisesExport("open"); +let path: string; +let root: string; +let savedPath: string | undefined; +let mode: "replace" | "rewrite" | "parent" | "denied" | "cancel"; +let closed = 0; +let controller = new AbortController(); +const scoped = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "node:fs/promises", + overrides: { + open: async (...args: Parameters) => { + if (args[0] !== path) { + return await realOpen(...args); + } + if (mode === "denied") { + throw new Error("private-policy-sentinel"); + } + if (mode === "replace") { + await rename(path, `${path}-old`); + await Bun.write(path, '{"default_domain":"other.invalid"}'); + } + if (mode === "parent") { + await rename(dirname(path), `${dirname(path)}-old`); + await symlink(`${dirname(path)}-old`, dirname(path)); + } + const file = await realOpen(...args); + let changed = false; + return new Proxy(file, { + get: (handle, property) => { + if (property === "close") { + return async () => { + closed++; + await handle.close(); + }; + } + if (property === "read") { + return async (...readArgs: unknown[]) => { + const result: unknown = await Reflect.apply( + handle.read, + handle, + readArgs + ); + if (!changed) { + changed = true; + if (mode === "rewrite") { + await Bun.write(path, '{"default_domain":"other.invalid"}'); + } + if (mode === "cancel") { + controller.abort("private-policy-sentinel"); + } + } + return result; + }; + } + const value: unknown = Reflect.get(handle, property, handle); + return typeof value === "function" ? value.bind(handle) : value; + }, + }); + }, + }, +}); +const { acquireNativeGlobalDomain } = await import( + "../src/lib/native-routing-inputs.ts" +); +beforeAll(() => scoped.activate()); +afterAll(() => scoped.deactivate()); +beforeEach(async () => { + savedPath = process.env.HACK_GLOBAL_CONFIG_PATH; + root = await realpath(await mkdtemp(join(tmpdir(), "native-routing-race-"))); + await mkdir(join(root, "policy")); + path = join(root, "policy/config.json"); + await Bun.write(path, '{"default_domain":"first.invalid"}'); + process.env.HACK_GLOBAL_CONFIG_PATH = path; + closed = 0; + controller = new AbortController(); +}); +afterEach(async () => { + restoreEnv("HACK_GLOBAL_CONFIG_PATH", savedPath); + await rm(root, { recursive: true, force: true }); +}); +for (const mutation of [ + "replace", + "rewrite", + "parent", + "denied", + "cancel", +] as const) { + test(`global routing policy ${mutation} refuses at acquisition boundary and closes owned descriptor`, async () => { + mode = mutation; + const error: unknown = await acquireNativeGlobalDomain({ + signal: controller.signal, + }).catch((value: unknown) => value); + expect(error).toMatchObject({ + code: mutation === "cancel" ? "E_COMPILER_CANCELLED" : "E_CONFIG_INPUT", + }); + expect(String(error)).not.toContain("private-policy-sentinel"); + expect(String(error)).not.toContain(root); + expect(closed).toBe(mutation === "denied" ? 0 : 1); + }); +} diff --git a/tests/native-routing-inputs.test.ts b/tests/native-routing-inputs.test.ts new file mode 100644 index 000000000..21cc22c01 --- /dev/null +++ b/tests/native-routing-inputs.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { acquireNativeGlobalDomain } from "../src/lib/native-routing-inputs.ts"; +import { resolveVerifiedNativeBranch } from "../src/lib/worktree-local-config.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const KEYS = [ + "PATH", + "HACK_HOME", + "HACK_GLOBAL_CONFIG_PATH", + "CI", + "HACK_EXECUTION_MODE", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", +] as const; +const SENTINEL = "private-routing-sentinel"; +let root: string; +let saved: Record; +beforeEach(async () => { + saved = Object.fromEntries(KEYS.map((key) => [key, process.env[key]])); + for (const key of KEYS) { + if (key !== "PATH") { + Reflect.deleteProperty(process.env, key); + } + } + root = await realpath( + await mkdtemp(join(tmpdir(), "native-routing-inputs-")) + ); + process.env.HACK_HOME = join(root, "home"); +}); +afterEach(async () => { + for (const key of KEYS) { + restoreEnv(key, saved[key]); + } + await rm(root, { recursive: true, force: true }); +}); +async function failure(operation: Promise, code = "E_CONFIG_INPUT") { + const error: unknown = await operation.catch((value: unknown) => value); + expect(error).toMatchObject({ code }); + expect(String(error)).not.toContain(SENTINEL); + expect(String(error)).not.toContain(root); + expect(error).not.toHaveProperty("cause"); +} +async function policy(text: string | Uint8Array) { + await mkdir(join(root, "home"), { recursive: true }); + const path = join(root, "home/hack.config.json"); + await writeFile(path, text); + return path; +} +async function git(cwd: string, args: readonly string[]) { + const child = Bun.spawn(["git", "-C", cwd, ...args], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + expect(await child.exited).toBe(0); +} +async function linked(branch = "feature/api") { + const primary = join(root, "primary"); + await mkdir(primary); + await git(primary, ["init", "--quiet", "-b", "main"]); + await git(primary, [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "--quiet", + "-m", + "fixture", + ]); + const checkout = join(root, "checkout"); + await git(primary, ["worktree", "add", "--quiet", "-b", branch, checkout]); + return { primary, checkout }; +} +const branch = (projectRoot: string, autoBranch = true) => + resolveVerifiedNativeBranch({ projectRoot, autoBranch }); + +test("global absence and unrelated policy return only an optional domain scalar", async () => { + expect(await acquireNativeGlobalDomain()).toBeUndefined(); + await policy(JSON.stringify({ unrelated: { secret: SENTINEL } })); + expect(await acquireNativeGlobalDomain()).toBeUndefined(); + await policy( + JSON.stringify({ + default_domain: "EXAMPLE.invalid", + unrelated: { secret: SENTINEL }, + }) + ); + expect(await acquireNativeGlobalDomain()).toBe("EXAMPLE.invalid"); + // Syntax and canonicalization belong to Rust, not the acquisition adapter. + await policy(JSON.stringify({ default_domain: "not/a/domain" })); + expect(await acquireNativeGlobalDomain()).toBe("not/a/domain"); +}); + +test("explicit global path uses the same strict metadata-only read", async () => { + const path = join(root, "custom.json"); + await writeFile(path, JSON.stringify({ default_domain: "custom.invalid" })); + process.env.HACK_GLOBAL_CONFIG_PATH = path; + expect(await acquireNativeGlobalDomain()).toBe("custom.invalid"); +}); + +for (const kind of [ + "malformed", + "array", + "null", + "wrong-type", + "unreadable", + "oversized", + "directory", + "symlink", + "dangling", + "utf8", +]) { + test(`global ${kind} is a redacted failure rather than absent policy`, async () => { + const path = await policy("{}"); + if (kind === "malformed") { + await writeFile(path, `{"secret":${SENTINEL}`); + } + if (kind === "array") { + await writeFile(path, "[]"); + } + if (kind === "null") { + await writeFile(path, "null"); + } + if (kind === "wrong-type") { + await writeFile( + path, + JSON.stringify({ default_domain: { secret: SENTINEL } }) + ); + } + if (kind === "unreadable") { + await chmod(path, 0); + } + if (kind === "oversized") { + await writeFile(path, " ".repeat(1024 * 1024 + 1)); + } + if (kind === "utf8") { + await writeFile(path, new Uint8Array([0xff])); + } + if (["directory", "symlink", "dangling"].includes(kind)) { + await rm(path); + if (kind === "directory") { + await mkdir(path); + } else { + const target = join(root, SENTINEL); + if (kind === "symlink") { + await writeFile(target, "{}"); + } + await symlink(target, path); + } + } + await failure(acquireNativeGlobalDomain()); + }); +} + +test("redirected global parent and cancellation refuse without values", async () => { + await mkdir(join(root, "target")); + await symlink(join(root, "target"), join(root, "home")); + await failure(acquireNativeGlobalDomain()); + await failure( + acquireNativeGlobalDomain({ signal: AbortSignal.abort(SENTINEL) }), + "E_COMPILER_CANCELLED" + ); +}); + +test("namespace is linked-only and uses stable sanitization while ignoring caller Git redirection", async () => { + const { primary, checkout } = await linked(); + expect(await branch(primary)).toBeUndefined(); + expect(await branch(root)).toBeUndefined(); + expect(await branch(checkout)).toBe("feature-api"); + process.env.GIT_DIR = join(root, SENTINEL); + process.env.GIT_WORK_TREE = join(root, SENTINEL); + process.env.GIT_COMMON_DIR = join(root, SENTINEL); + expect(await branch(checkout)).toBe("feature-api"); + await mkdir(join(checkout, "nested")); + expect(await branch(join(checkout, "nested"))).toBeUndefined(); +}); + +test("colliding worktree branches follow the existing raw-name hash suffix convention", async () => { + const { primary, checkout } = await linked(); + const other = join(root, "other"); + await git(primary, [ + "worktree", + "add", + "--quiet", + "-b", + "feature-api", + other, + ]); + const hash = (name: string) => + new Bun.CryptoHasher("sha1").update(name).digest("hex").slice(0, 4); + expect(await branch(checkout)).toBe(`feature-api-${hash("feature/api")}`); + expect(await branch(other)).toBe(`feature-api-${hash("feature-api")}`); +}); + +test("auto_branch optout and runner exclusions avoid unsafe linkage inspection", async () => { + const { checkout } = await linked(); + await writeFile(join(checkout, ".git"), `gitdir: ${SENTINEL}`); + expect(await branch(checkout, false)).toBeUndefined(); + process.env.CI = "true"; + expect(await branch(checkout)).toBeUndefined(); + Reflect.deleteProperty(process.env, "CI"); + process.env.HACK_EXECUTION_MODE = "slim"; + expect(await branch(checkout)).toBeUndefined(); +}); + +test("linked detached, oversized branch and redirected root refuse rather than share base", async () => { + const { checkout } = await linked(); + await git(checkout, ["checkout", "--quiet", "--detach"]); + await failure(branch(checkout), "E_CONFIG_INVALID"); + await git(checkout, ["checkout", "--quiet", "-b", "a".repeat(64)]); + await failure(branch(checkout), "E_CONFIG_INVALID"); + const redirect = join(root, "redirect"); + await symlink(checkout, redirect); + await failure(branch(redirect), "E_CONFIG_INVALID"); + await failure( + resolveVerifiedNativeBranch({ + projectRoot: checkout, + autoBranch: true, + signal: AbortSignal.abort(SENTINEL), + }), + "E_COMPILER_CANCELLED" + ); +}); + +test("branch generation changing during repeated inspection refuses instead of returning a stale namespace", async () => { + const { checkout } = await linked(); + const actualGit = Bun.which("git"); + if (!actualGit) { + throw new Error("Git fixture executable missing"); + } + const bin = join(root, "bin"); + const count = join(root, "inspections"); + await mkdir(bin); + const wrapper = join(bin, "git"); + await writeFile( + wrapper, + `#!${process.execPath} +const args=process.argv.slice(2); +if(args.includes('symbolic-ref')) { + const path=${JSON.stringify(count)}; + const prior=await Bun.file(path).exists()?Number(await Bun.file(path).text()):0; + await Bun.write(path,String(prior+1)); + if(prior===1) { const change=Bun.spawn([${JSON.stringify(actualGit)},'-C',${JSON.stringify(checkout)},'branch','-m','changed-branch'],{stdout:'ignore',stderr:'ignore'}); if(await change.exited!==0) process.exit(2); } +} +const child=Bun.spawn([${JSON.stringify(actualGit)},...args],{stdin:'ignore',stdout:'inherit',stderr:'ignore'});process.exit(await child.exited); +` + ); + await chmod(wrapper, 0o700); + process.env.PATH = bin; + await failure(branch(checkout), "E_CONFIG_INVALID"); + expect(await Bun.file(count).text()).toBe("2"); +}); diff --git a/tests/native-routing-plan-transport.test.ts b/tests/native-routing-plan-transport.test.ts new file mode 100644 index 000000000..1001754bb --- /dev/null +++ b/tests/native-routing-plan-transport.test.ts @@ -0,0 +1,1279 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + compileNativeConfig, + NATIVE_CONFIG_INPUT_LIMIT, + NativeConfigCompilerError, + planNativeConfig, + resolveNativeConfig, +} from "../src/lib/native-config-compiler.ts"; +import type { NativeEnvMetadata } from "../src/lib/native-env-plan-protocol.ts"; +import type { NativeRoutingResolution } from "../src/lib/native-routing-plan-protocol.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const PROTOCOL = { + transport_version: 1, + authored_version: 1, + plan_version: 1, + resolve_version: 1, + local_version: 1, + env_plan_version: 1, + host_env_plan_version: 1, + routing_plan_version: 1, +}; +const { routing_plan_version, ...ROUTINGLESS_PROTOCOL } = PROTOCOL; +void routing_plan_version; +const INPUT = new TextEncoder().encode('{"schema_version":1,"name":"fixture"}'); +const CANARY = "routing-wire-private-canary"; +const METADATA = { + metadata_version: 1, + overlay: "qa", + overlay_exists: true, + workloads: { web: {} }, + inactive_scopes: [], +} as const satisfies NativeEnvMetadata; +const ROUTINGLESS_SUCCESS = { + transport_version: 1, + ok: true, + plan: { + plan_version: 1, + name: "fixture", + services: { web: {} }, + jobs: {}, + worktree: { inherit_local: false }, + }, + semantic_hash: "a".repeat(64), + declared_workloads: { web: "service", inactive: "service", seed: "job" }, + local_resolution: { + overlay: "qa", + origin: "checkout_local", + auto_branch: false, + inherit_local: false, + resolution_hash: "b".repeat(64), + }, + environment_plan: { + plan_version: 1, + overlay: "qa", + overlay_exists: true, + complete: true, + workloads: { web: {} }, + warnings: [], + diagnostics: [], + }, +} as const; +const HTTP_ROUTE = { + service: "web", + port: 3000, + protocol: "http", + hostname: "web", +} as const; +const ROUTES_PLAN = { + domain: "dev.test", + origin: "https://fixture.dev.test", + aliases: { + preview: { origin: "https://preview.test" }, + oauth: { domain: "login.test" }, + }, + oauth_alias: "oauth", + http: { + web: HTTP_ROUTE, + inactive: { ...HTTP_ROUTE, service: "inactive", hostname: "inactive" }, + }, +} as const; +const ROUTING_RESOLUTION = { + domain: "dev.test", + domain_origin: "project", + project_origin: "https://fixture.dev.test", + aliases: { + preview: "https://preview.test", + oauth: "https://fixture.login.test", + }, + oauth_alias: "oauth", + open_preference: "alias", + open_preference_origin: "project", + open_origin: "https://fixture.login.test", + routes: { + web: { + service: "web", + port: 3000, + protocol: "http", + origin: "https://web.fixture.dev.test", + aliases: { + preview: "https://web.preview.test", + oauth: "https://web.fixture.login.test", + }, + }, + }, +} as const satisfies NativeRoutingResolution; +const COMPILE_SUCCESS = { + ...ROUTINGLESS_SUCCESS, + plan: { + ...ROUTINGLESS_SUCCESS.plan, + routes: ROUTES_PLAN, + open: { prefer: "alias" }, + }, +} as const; +const SUCCESS = { ...COMPILE_SUCCESS, routing_resolution: ROUTING_RESOLUTION }; +const PROBE_SUCCESS = { + ...COMPILE_SUCCESS, + routing_inputs_required: true, +} as const; +const EMPTY_RESOLUTION = { + ...ROUTING_RESOLUTION, + domain: "hack.local", + domain_origin: "default", + project_origin: "https://fixture.hack.local", + aliases: {}, + oauth_alias: null, + open_preference: "auto", + open_preference_origin: "default", + open_origin: "https://fixture.hack.local", + routes: {}, +} as const satisfies NativeRoutingResolution; +const BRANCHED_SUCCESS = { + ...SUCCESS, + routing_resolution: { + ...ROUTING_RESOLUTION, + domain_origin: "explicit", + branch: "feature-routing", + aliases: { + ...ROUTING_RESOLUTION.aliases, + oauth: "https://feature-routing.fixture.login.test", + }, + open_origin: "https://feature-routing.fixture.login.test", + routes: { + web: { + ...ROUTING_RESOLUTION.routes.web, + aliases: { + ...ROUTING_RESOLUTION.routes.web.aliases, + oauth: "https://web.feature-routing.fixture.login.test", + }, + }, + }, + }, +} as const; +let directory = ""; + +beforeEach(async () => { + directory = await realpath( + await mkdtemp(join(tmpdir(), "hack-routing-plan-transport-")) + ); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function fixture(opts: { + readonly response?: unknown; + readonly protocol?: Readonly>; + readonly body?: string; + readonly onInvocation?: string; + readonly exitCode?: number; +}): Promise { + const path = join(directory, "compiler"); + const body = + opts.body ?? + `process.stdout.write(${JSON.stringify(JSON.stringify(opts.response ?? ROUTINGLESS_SUCCESS))}); process.stderr.write(${JSON.stringify(CANARY)}); process.exitCode=${opts.exitCode ?? 0};`; + await Bun.write( + path, + `#!${process.execPath}\n${opts.onInvocation ?? ""}\nif (process.argv[2] === '--protocol') { process.stdout.write(${JSON.stringify(JSON.stringify(opts.protocol ?? PROTOCOL))}); } else { ${body} }\n` + ); + await chmod(path, 0o755); + return path; +} + +async function expectFailure(opts: { + readonly operation: Promise; + readonly code: string; + readonly message?: string; +}): Promise { + const error: unknown = await opts.operation.catch((value: unknown) => value); + expect(error).toBeInstanceOf(NativeConfigCompilerError); + if (!(error instanceof NativeConfigCompilerError)) { + throw new Error("Expected a fixed routing transport failure"); + } + expect(error.code).toBe(opts.code); + if (opts.message !== undefined) { + expect(error.message).toBe(opts.message); + } + expect(error.message).not.toContain(CANARY); + expect(error.message).not.toContain(directory); +} + +function withResolution( + overrides: Readonly> +): Record { + return { + ...SUCCESS, + routing_resolution: { ...ROUTING_RESOLUTION, ...overrides }, + }; +} + +function withResolvedRoute( + overrides: Readonly> +): Record { + return withResolution({ + routes: { web: { ...ROUTING_RESOLUTION.routes.web, ...overrides } }, + }); +} + +function withRoutesPlan( + overrides: Readonly> +): Record { + return { + ...COMPILE_SUCCESS, + plan: { ...COMPILE_SUCCESS.plan, routes: { ...ROUTES_PLAN, ...overrides } }, + }; +} + +test.each([ + { name: "missing", version: undefined }, + { name: "null", version: null }, + { name: "unsupported", version: 2 }, +])("requires routing capability before payload: $name", async ({ version }) => { + const receipt = join(directory, "payload-receipt"); + const protocolReceipt = join(directory, "protocol-input-receipt"); + const binary = await fixture({ + protocol: { ...PROTOCOL, routing_plan_version: version }, + onInvocation: `if (process.argv[2] === '--protocol') { await Bun.write(${JSON.stringify(protocolReceipt)}, await Bun.stdin.text()); }`, + body: `await Bun.write(${JSON.stringify(receipt)}, await Bun.stdin.text());`, + }); + for (const operation of [ + () => + compileNativeConfig({ + input: INPUT, + binary, + requireRoutingPlanning: true, + }), + () => + resolveNativeConfig({ + input: INPUT, + binary, + requireRoutingPlanning: true, + }), + () => + planNativeConfig({ + input: INPUT, + binary, + envMetadata: METADATA, + requireRoutingPlanning: true, + }), + ]) { + await expectFailure({ + operation: operation(), + code: "E_COMPILER_VERSION", + message: "Native configuration compiler version mismatch.", + }); + expect(await Bun.file(protocolReceipt).text()).toBe(""); + expect(await Bun.file(receipt).exists()).toBe(false); + } +}); + +test.each([ + { name: "older env v1", protocol: ROUTINGLESS_PROTOCOL }, + { name: "routing-capable", protocol: PROTOCOL }, +])("preserves routingless compile, resolve, and planning compatibility: $name", async ({ + protocol, +}) => { + const binary = await fixture({ protocol }); + expect((await compileNativeConfig({ input: INPUT, binary })).ok).toBe(true); + expect((await resolveNativeConfig({ input: INPUT, binary })).ok).toBe(true); + expect( + await planNativeConfig({ input: INPUT, binary, envMetadata: METADATA }) + ).toEqual(ROUTINGLESS_SUCCESS); +}); + +test("an active routing probe sends one original-document request and returns only the routing need", async () => { + const receipt = join(directory, "resolve-requests"); + const projectText = '\ufeff{"name":"first","name":"second"}\n'; + const localText = "null\n"; + const input = new TextEncoder().encode(projectText); + const checkoutLocal = new TextEncoder().encode(localText); + const binary = await fixture({ + body: `const payload=JSON.parse(await Bun.stdin.text()); const {appendFileSync}=await import('node:fs'); appendFileSync(${JSON.stringify(receipt)}, JSON.stringify({command:process.argv[2],payload})+'\\n'); process.stdout.write(${JSON.stringify(JSON.stringify({ ...PROBE_SUCCESS, private_payload: CANARY }))});`, + }); + const result = await resolveNativeConfig({ + input, + checkoutLocal, + probeRoutingInputs: true, + binary, + }); + const { environment_plan, ...expected } = PROBE_SUCCESS; + void environment_plan; + expect(result).toEqual(expected); + expect(result).not.toHaveProperty("routing_resolution"); + expect(result).not.toHaveProperty("envelope"); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(await Bun.file(receipt).text()).toBe( + `${JSON.stringify({ + command: "resolve", + payload: { + request_version: 1, + project: projectText, + checkout_local: localText, + routing_probe: true, + }, + })}\n` + ); + expect(input).toEqual(new TextEncoder().encode(projectText)); + expect(checkoutLocal).toEqual(new TextEncoder().encode(localText)); +}); + +test.each([ + { name: "inactive routing probe", protocol: PROTOCOL, probe: true }, + { + name: "older compiler fallback", + protocol: ROUTINGLESS_PROTOCOL, + probe: undefined, + }, +])("resolves without an expanded routing report: $name", async ({ + protocol, + probe, +}) => { + const receipt = join(directory, "resolve-request"); + const binary = await fixture({ + protocol, + body: `await Bun.write(${JSON.stringify(receipt)}, await Bun.stdin.text()); process.stdout.write(${JSON.stringify(JSON.stringify(ROUTINGLESS_SUCCESS))});`, + }); + const result = await resolveNativeConfig({ + input: INPUT, + probeRoutingInputs: true, + binary, + }); + const { environment_plan, ...expected } = ROUTINGLESS_SUCCESS; + void environment_plan; + expect(result).toEqual(expected); + expect(result).not.toHaveProperty("routing_inputs_required"); + expect(result).not.toHaveProperty("routing_resolution"); + expect(JSON.parse(await Bun.file(receipt).text())).toEqual({ + request_version: 1, + project: new TextDecoder().decode(INPUT), + ...(probe ? { routing_probe: true } : {}), + }); +}); + +test.each([ + { name: "false", marker: false }, + { name: "null", marker: null }, +])("refuses an active probe's malformed routing marker: $name", async ({ + marker, +}) => { + const binary = await fixture({ + response: { ...COMPILE_SUCCESS, routing_inputs_required: marker }, + }); + await expectFailure({ + operation: resolveNativeConfig({ + input: INPUT, + binary, + probeRoutingInputs: true, + }), + code: "E_COMPILER_RESPONSE", + message: "Native routing input probe returned an invalid result.", + }); +}); + +test.each([ + { name: "ordinary resolve", command: "resolve" }, + { name: "context-free compile", command: "compile" }, + { name: "environment plan", command: "plan" }, +])("refuses an unrequested routing marker from $name", async ({ command }) => { + const binary = await fixture({ + response: + command === "compile" + ? PROBE_SUCCESS + : { ...SUCCESS, routing_inputs_required: true }, + }); + const operation = + command === "compile" + ? compileNativeConfig({ input: INPUT, binary }) + : command === "plan" + ? planNativeConfig({ input: INPUT, binary, envMetadata: METADATA }) + : resolveNativeConfig({ input: INPUT, binary }); + await expectFailure({ operation, code: "E_COMPILER_RESPONSE" }); +}); + +test("an older compiler fallback refuses a probe-only routing marker", async () => { + const binary = await fixture({ + protocol: ROUTINGLESS_PROTOCOL, + response: { ...ROUTINGLESS_SUCCESS, routing_inputs_required: true }, + }); + await expectFailure({ + operation: resolveNativeConfig({ + input: INPUT, + binary, + probeRoutingInputs: true, + }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test.each([ + { + name: "with marker", + response: { ...SUCCESS, routing_inputs_required: true }, + }, + { name: "without marker", response: SUCCESS }, +])("refuses an expanded routing report during a probe: $name", async ({ + response, +}) => { + const binary = await fixture({ response }); + await expectFailure({ + operation: resolveNativeConfig({ + input: INPUT, + binary, + probeRoutingInputs: true, + }), + code: "E_COMPILER_RESPONSE", + message: "Native routing input probe returned an invalid result.", + }); +}); + +test.each([ + { name: "routes", response: COMPILE_SUCCESS }, + { + name: "open preference", + response: { + ...ROUTINGLESS_SUCCESS, + plan: { ...ROUTINGLESS_SUCCESS.plan, open: { prefer: "auto" } }, + }, + }, + { name: "explicitly required routing", response: ROUTINGLESS_SUCCESS }, +])("refuses a missing probe marker when $name is enabled", async ({ + name, + response, +}) => { + const binary = await fixture({ response }); + await expectFailure({ + operation: resolveNativeConfig({ + input: INPUT, + binary, + probeRoutingInputs: true, + requireRoutingPlanning: name === "explicitly required routing", + }), + code: "E_COMPILER_RESPONSE", + message: "Native routing input probe returned an invalid result.", + }); +}); + +test("routing selection strings force capability checks before payload without an explicit flag", async () => { + const receipt = join(directory, "payload-receipt"); + const binary = await fixture({ + protocol: ROUTINGLESS_PROTOCOL, + body: `await Bun.write(${JSON.stringify(receipt)}, await Bun.stdin.text());`, + }); + for (const selection of [ + { explicitDomain: "dev.test" }, + { globalDomain: "global.test" }, + { branch: "feature-routing" }, + ]) { + for (const operation of [ + () => resolveNativeConfig({ input: INPUT, binary, ...selection }), + () => + planNativeConfig({ + input: INPUT, + binary, + envMetadata: METADATA, + ...selection, + }), + ]) { + await expectFailure({ + operation: operation(), + code: "E_COMPILER_VERSION", + }); + expect(await Bun.file(receipt).exists()).toBe(false); + } + } +}); + +test("preserves routing request text, document bytes, and PATH-only compiler environment", async () => { + const projectText = '\ufeff{"name":"first","name":"second"}\n'; + const localText = "null\n"; + const input = new TextEncoder().encode(projectText); + const checkoutLocal = new TextEncoder().encode(localText); + const originalInput = input.slice(); + const originalLocal = checkoutLocal.slice(); + const originalEnv = process.env.HACK_TEST_ROUTING_PLAN_PRIVATE; + process.env.HACK_TEST_ROUTING_PLAN_PRIVATE = CANARY; + try { + const binary = await fixture({ + body: `const received=JSON.parse(await Bun.stdin.text()); const result=${JSON.stringify(BRANCHED_SUCCESS)}; result.plan.received=received; result.plan.arguments=process.argv.slice(2); result.plan.environment=process.env; process.stdout.write(JSON.stringify(result));`, + }); + const result = await planNativeConfig({ + input, + checkoutLocal, + explicitDomain: "dev.test", + globalDomain: "global.test", + branch: "feature-routing", + envMetadata: METADATA, + profiles: ["with spaces", "--unsafe"], + binary, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("Expected routing planning success"); + } + expect(result.plan.received).toEqual({ + request_version: 1, + project: projectText, + checkout_local: localText, + explicit_domain: "dev.test", + global_domain: "global.test", + branch: "feature-routing", + env_metadata: METADATA, + }); + expect(result.plan.arguments).toEqual([ + "plan", + "--profile", + "with spaces", + "--profile", + "--unsafe", + ]); + expect(result.plan.environment).toEqual({ PATH: "/usr/bin:/bin" }); + expect(result.routing_resolution).toEqual( + BRANCHED_SUCCESS.routing_resolution + ); + expect(input).toEqual(originalInput); + expect(checkoutLocal).toEqual(originalLocal); + } finally { + restoreEnv("HACK_TEST_ROUTING_PLAN_PRIVATE", originalEnv); + } +}); + +test("compiles declarations without a resolution and reports only routes for selected services", async () => { + const binary = await fixture({ + body: `process.stdout.write(JSON.stringify(process.argv[2] === 'compile' ? ${JSON.stringify(COMPILE_SUCCESS)} : ${JSON.stringify(SUCCESS)}));`, + }); + const compiled = await compileNativeConfig({ input: INPUT, binary }); + expect(compiled.ok).toBe(true); + expect(compiled).not.toHaveProperty("routing_resolution"); + const resolved = await resolveNativeConfig({ input: INPUT, binary }); + expect(resolved.ok).toBe(true); + if (!resolved.ok) { + throw new Error("Expected selected route resolution success"); + } + expect(resolved.routing_resolution).toEqual(ROUTING_RESOLUTION); + expect(resolved.routing_resolution?.routes).not.toHaveProperty("inactive"); +}); + +test.each([ + { name: "routes plan", compiled: COMPILE_SUCCESS, resolved: SUCCESS }, + { + name: "open plan", + compiled: { + ...ROUTINGLESS_SUCCESS, + plan: { ...ROUTINGLESS_SUCCESS.plan, open: { prefer: "auto" } }, + }, + resolved: { + ...ROUTINGLESS_SUCCESS, + plan: { ...ROUTINGLESS_SUCCESS.plan, open: { prefer: "auto" } }, + routing_resolution: EMPTY_RESOLUTION, + }, + }, +])("refuses routing from an older handshake before returning a result: $name", async ({ + compiled, + resolved, +}) => { + const binary = await fixture({ + protocol: ROUTINGLESS_PROTOCOL, + body: `process.stdout.write(JSON.stringify(process.argv[2] === 'compile' ? ${JSON.stringify(compiled)} : ${JSON.stringify(resolved)}));`, + }); + for (const operation of [ + () => compileNativeConfig({ input: INPUT, binary }), + () => resolveNativeConfig({ input: INPUT, binary }), + () => planNativeConfig({ input: INPUT, binary, envMetadata: METADATA }), + ]) { + await expectFailure({ operation: operation(), code: "E_COMPILER_VERSION" }); + } +}); + +test("a resolution alone requires routing capability even without routing declarations", async () => { + const binary = await fixture({ + protocol: ROUTINGLESS_PROTOCOL, + response: { ...ROUTINGLESS_SUCCESS, routing_resolution: EMPTY_RESOLUTION }, + }); + await expectFailure({ + operation: resolveNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_VERSION", + }); +}); + +test.each([ + { name: "older compiler", protocol: ROUTINGLESS_PROTOCOL }, + { name: "routing-capable compiler", protocol: PROTOCOL }, +])("context-free compile refuses a routing resolution: $name", async ({ + protocol, +}) => { + const binary = await fixture({ protocol, response: SUCCESS }); + await expectFailure({ + operation: compileNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test.each([ + { name: "routes enabled", response: COMPILE_SUCCESS }, + { + name: "open enabled", + response: { + ...ROUTINGLESS_SUCCESS, + plan: { ...ROUTINGLESS_SUCCESS.plan, open: { prefer: "auto" } }, + }, + }, + { name: "routing explicitly required", response: ROUTINGLESS_SUCCESS }, +])("refuses an omitted routing report when $name", async ({ response }) => { + const binary = await fixture({ response }); + for (const operation of [ + () => + resolveNativeConfig({ + input: INPUT, + binary, + requireRoutingPlanning: true, + }), + () => + planNativeConfig({ + input: INPUT, + binary, + envMetadata: METADATA, + requireRoutingPlanning: true, + }), + ]) { + await expectFailure({ + operation: operation(), + code: "E_COMPILER_RESPONSE", + }); + } +}); + +test("accepts explicit empty routing declarations with an empty report", async () => { + const response = { + ...ROUTINGLESS_SUCCESS, + plan: { ...ROUTINGLESS_SUCCESS.plan, routes: { aliases: {}, http: {} } }, + routing_resolution: EMPTY_RESOLUTION, + }; + const binary = await fixture({ response }); + const { environment_plan, ...expected } = response; + void environment_plan; + expect(await resolveNativeConfig({ input: INPUT, binary })).toEqual(expected); +}); + +test("project hostname legitimately retains unprefixed project and alias origins", async () => { + const response = { + ...SUCCESS, + plan: { + ...COMPILE_SUCCESS.plan, + routes: { + ...ROUTES_PLAN, + http: { web: { ...HTTP_ROUTE, hostname: "project" } }, + }, + }, + routing_resolution: { + ...ROUTING_RESOLUTION, + routes: { + web: { + ...ROUTING_RESOLUTION.routes.web, + origin: ROUTING_RESOLUTION.project_origin, + aliases: ROUTING_RESOLUTION.aliases, + }, + }, + }, + }; + const binary = await fixture({ response }); + const { environment_plan, ...expected } = response; + void environment_plan; + expect(await resolveNativeConfig({ input: INPUT, binary })).toEqual(expected); +}); + +test("bounds domain and branch context before spawning", async () => { + const receipt = join(directory, "spawn-receipt"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + }); + for (const field of ["explicitDomain", "globalDomain", "branch"]) { + await expectFailure({ + operation: resolveNativeConfig({ + input: INPUT, + binary, + [field]: "x".repeat(NATIVE_CONFIG_INPUT_LIMIT + 1), + }), + code: "E_CONFIG_INPUT", + message: "Native routing selection is invalid or exceeds its budget.", + }); + expect(await Bun.file(receipt).exists()).toBe(false); + } +}); + +test.each([ + { + name: "null routes", + response: { + ...COMPILE_SUCCESS, + plan: { ...COMPILE_SUCCESS.plan, routes: null }, + }, + }, + { name: "unknown routes field", response: withRoutesPlan({ value: CANARY }) }, + { name: "missing aliases", response: withRoutesPlan({ aliases: undefined }) }, + { name: "array route map", response: withRoutesPlan({ http: [] }) }, + { + name: "noncanonical domain", + response: withRoutesPlan({ domain: "Private.TEST" }), + }, + { + name: "domain path", + response: withRoutesPlan({ domain: `private.test/${CANARY}` }), + }, + { + name: "alias with both encodings", + response: withRoutesPlan({ + aliases: { + oauth: { domain: "login.test", origin: "https://login.test" }, + }, + }), + }, + { + name: "alias sensitive field", + response: withRoutesPlan({ + aliases: { oauth: { domain: "login.test", value: CANARY } }, + }), + }, + { + name: "missing OAuth alias", + response: withRoutesPlan({ oauth_alias: "missing" }), + }, + { + name: "undeclared service", + response: withRoutesPlan({ + http: { web: { ...HTTP_ROUTE, service: "ghost" } }, + }), + }, + { + name: "job target", + response: withRoutesPlan({ + http: { web: { ...HTTP_ROUTE, service: "seed" } }, + }), + }, + { + name: "missing declared namespace", + response: { ...COMPILE_SUCCESS, declared_workloads: undefined }, + }, + { + name: "noncanonical route name", + response: withRoutesPlan({ http: { "../private": HTTP_ROUTE } }), + }, + { + name: "out-of-range port", + response: withRoutesPlan({ + http: { web: { ...HTTP_ROUTE, port: 65_536 } }, + }), + }, + { + name: "fractional port", + response: withRoutesPlan({ http: { web: { ...HTTP_ROUTE, port: 1.5 } } }), + }, + { + name: "unknown protocol", + response: withRoutesPlan({ + http: { web: { ...HTTP_ROUTE, protocol: "ftp" } }, + }), + }, + { + name: "invalid hostname", + response: withRoutesPlan({ + http: { web: { ...HTTP_ROUTE, hostname: `web/${CANARY}` } }, + }), + }, + { + name: "unknown route field", + response: withRoutesPlan({ + http: { web: { ...HTTP_ROUTE, path: CANARY } }, + }), + }, + { + name: "unknown open preference", + response: { + ...COMPILE_SUCCESS, + plan: { ...COMPILE_SUCCESS.plan, open: { prefer: CANARY } }, + }, + }, + { + name: "unknown open field", + response: { + ...COMPILE_SUCCESS, + plan: { ...COMPILE_SUCCESS.plan, open: { prefer: "auto", path: CANARY } }, + }, + }, +])("refuses malformed normalized routing declarations: $name", async ({ + response, +}) => { + const binary = await fixture({ response }); + await expectFailure({ + operation: compileNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test.each([ + { name: "null report", response: { ...SUCCESS, routing_resolution: null } }, + { + name: "unknown report field", + response: withResolution({ ciphertext: CANARY }), + }, + { name: "missing domain", response: withResolution({ domain: undefined }) }, + { + name: "noncanonical domain", + response: withResolution({ domain: "Private.TEST" }), + }, + { + name: "unknown domain source", + response: withResolution({ domain_origin: CANARY }), + }, + { + name: "unknown open preference", + response: withResolution({ open_preference: CANARY }), + }, + { + name: "unknown open source", + response: withResolution({ open_preference_origin: "global" }), + }, + { + name: "unknown OAuth alias", + response: withResolution({ oauth_alias: "missing" }), + }, + { + name: "OAuth reference mismatch", + response: withResolution({ oauth_alias: null }), + }, + { + name: "invalid branch label", + response: withResolution({ branch: "feature/private" }), + }, + { + name: "oversized branch label", + response: withResolution({ branch: "x".repeat(64) }), + }, + { name: "missing route", response: withResolution({ routes: {} }) }, + { + name: "inactive route included", + response: withResolution({ + routes: { + ...ROUTING_RESOLUTION.routes, + inactive: { + ...ROUTING_RESOLUTION.routes.web, + service: "inactive", + origin: "https://inactive.fixture.dev.test", + }, + }, + }), + }, + { + name: "service mismatch", + response: withResolvedRoute({ service: "inactive" }), + }, + { name: "port mismatch", response: withResolvedRoute({ port: 3001 }) }, + { + name: "protocol mismatch", + response: withResolvedRoute({ protocol: "https" }), + }, + { + name: "route sensitive field", + response: withResolvedRoute({ path: CANARY }), + }, + { + name: "missing project alias", + response: withResolution({ aliases: { oauth: "https://login.test" } }), + }, + { + name: "missing scoped alias", + response: withResolvedRoute({ + aliases: { oauth: "https://web.login.test" }, + }), + }, + { + name: "foreign scoped alias", + response: withResolvedRoute({ + aliases: { + ...ROUTING_RESOLUTION.routes.web.aliases, + foreign: "https://foreign.test", + }, + }), + }, + { + name: "route origin scope mismatch", + response: withResolvedRoute({ origin: "https://other.fixture.dev.test" }), + }, + { + name: "route alias scope mismatch", + response: withResolvedRoute({ + aliases: { + ...ROUTING_RESOLUTION.routes.web.aliases, + oauth: ROUTING_RESOLUTION.aliases.oauth, + }, + }), + }, + { + name: "static project origin mismatch", + response: withResolution({ project_origin: "https://foreign.test" }), + }, + { + name: "static alias origin mismatch", + response: withResolution({ + aliases: { + ...ROUTING_RESOLUTION.aliases, + preview: "https://foreign.test", + }, + }), + }, + { + name: "open preference mismatch", + response: withResolution({ + open_preference: "dev", + open_origin: ROUTING_RESOLUTION.project_origin, + }), + }, + { + name: "open destination mismatch", + response: withResolution({ + open_origin: ROUTING_RESOLUTION.project_origin, + }), + }, +])("refuses malformed routing reports and foreign selection: $name", async ({ + response, +}) => { + const binary = await fixture({ response }); + await expectFailure({ + operation: resolveNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test.each([ + { name: "credentials", value: `https://user:${CANARY}@fixture.dev.test` }, + { name: "path", value: `https://fixture.dev.test/${CANARY}` }, + { name: "query", value: `https://fixture.dev.test?value=${CANARY}` }, + { name: "fragment", value: `https://fixture.dev.test#${CANARY}` }, + { name: "trailing slash", value: "https://fixture.dev.test/" }, + { name: "noncanonical casing", value: "https://FIXTURE.dev.test" }, + { name: "default port spelling", value: "https://fixture.dev.test:443" }, + { name: "foreign protocol", value: "ftp://fixture.dev.test" }, +])("refuses noncanonical or private origin encodings: $name", async ({ + value, +}) => { + for (const response of [ + withResolution({ project_origin: value }), + withResolution({ open_origin: value }), + withResolution({ + aliases: { ...ROUTING_RESOLUTION.aliases, oauth: value }, + }), + withResolvedRoute({ origin: value }), + withResolvedRoute({ + aliases: { ...ROUTING_RESOLUTION.routes.web.aliases, oauth: value }, + }), + ]) { + const binary = await fixture({ response }); + await expectFailure({ + operation: resolveNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); + } +}); + +test("refuses duplicate origins across aliases and selected route variants", async () => { + for (const response of [ + withResolution({ + aliases: { + ...ROUTING_RESOLUTION.aliases, + oauth: ROUTING_RESOLUTION.aliases.preview, + }, + open_origin: ROUTING_RESOLUTION.aliases.preview, + routes: { + web: { + ...ROUTING_RESOLUTION.routes.web, + aliases: { + ...ROUTING_RESOLUTION.routes.web.aliases, + oauth: ROUTING_RESOLUTION.routes.web.aliases.preview, + }, + }, + }, + }), + withResolution({ + aliases: { + ...ROUTING_RESOLUTION.aliases, + oauth: ROUTING_RESOLUTION.project_origin, + }, + open_origin: ROUTING_RESOLUTION.project_origin, + routes: { + web: { + ...ROUTING_RESOLUTION.routes.web, + aliases: { + ...ROUTING_RESOLUTION.routes.web.aliases, + oauth: ROUTING_RESOLUTION.routes.web.origin, + }, + }, + }, + }), + { + ...SUCCESS, + plan: { + ...COMPILE_SUCCESS.plan, + routes: { + ...ROUTES_PLAN, + http: { web: HTTP_ROUTE, other: HTTP_ROUTE }, + }, + }, + routing_resolution: { + ...ROUTING_RESOLUTION, + routes: { + ...ROUTING_RESOLUTION.routes, + other: ROUTING_RESOLUTION.routes.web, + }, + }, + }, + ]) { + const binary = await fixture({ response }); + await expectFailure({ + operation: resolveNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); + } +}); + +test("preserves canonical constructor alias keys as own properties through JSON roundtrip", async () => { + const aliases = Object.fromEntries([ + ["constructor", { origin: "https://constructor.test" }], + ]); + const resolvedAliases = Object.fromEntries([ + ["constructor", "https://constructor.test"], + ]); + const scopedAliases = Object.fromEntries([ + ["constructor", "https://web.constructor.test"], + ]); + const response = { + ...SUCCESS, + plan: { + ...COMPILE_SUCCESS.plan, + routes: { ...ROUTES_PLAN, aliases, oauth_alias: "constructor" }, + }, + routing_resolution: { + ...ROUTING_RESOLUTION, + aliases: resolvedAliases, + oauth_alias: "constructor", + open_origin: "https://constructor.test", + routes: { + web: { ...ROUTING_RESOLUTION.routes.web, aliases: scopedAliases }, + }, + }, + }; + const binary = await fixture({ response }); + const result = await resolveNativeConfig({ input: INPUT, binary }); + const { environment_plan, ...expected } = response; + void environment_plan; + expect(result).toEqual(expected); + if (!result.ok) { + throw new Error("Expected canonical own alias keys"); + } + expect( + Object.hasOwn(result.routing_resolution?.aliases ?? {}, "constructor") + ).toBe(true); + expect(JSON.parse(JSON.stringify(result.routing_resolution))).toEqual( + response.routing_resolution + ); +}); + +test("refuses an own __proto__ alias rather than losing it during projection", async () => { + const aliases = Object.fromEntries([ + ["__proto__", { origin: "https://private.test" }], + ]); + const binary = await fixture({ + response: withRoutesPlan({ aliases, oauth_alias: undefined }), + }); + await expectFailure({ + operation: compileNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test("refuses foreign generated project and domain-alias origins even when scoped URLs agree", async () => { + for (const response of [ + { + ...SUCCESS, + plan: { + ...COMPILE_SUCCESS.plan, + routes: { ...ROUTES_PLAN, origin: undefined }, + }, + routing_resolution: { + ...ROUTING_RESOLUTION, + project_origin: "https://foreign.dev.test", + routes: { + web: { + ...ROUTING_RESOLUTION.routes.web, + origin: "https://web.foreign.dev.test", + }, + }, + }, + }, + withResolution({ + aliases: { + ...ROUTING_RESOLUTION.aliases, + oauth: "https://foreign.login.test", + }, + open_origin: "https://foreign.login.test", + routes: { + web: { + ...ROUTING_RESOLUTION.routes.web, + aliases: { + ...ROUTING_RESOLUTION.routes.web.aliases, + oauth: "https://web.foreign.login.test", + }, + }, + }, + }), + ]) { + const binary = await fixture({ response }); + await expectFailure({ + operation: resolveNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); + } +}); + +test("refuses an unrequested branch even when generated alias and route URLs agree", async () => { + const binary = await fixture({ + response: { + ...BRANCHED_SUCCESS, + routing_resolution: { + ...BRANCHED_SUCCESS.routing_resolution, + domain_origin: "project", + }, + }, + }); + await expectFailure({ + operation: resolveNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test.each([ + { + name: "explicit source without selection", + selection: {}, + resolution: { domain_origin: "explicit" }, + }, + { + name: "explicit value mismatch", + selection: { explicitDomain: "dev.test" }, + resolution: { domain_origin: "explicit", domain: "foreign.test" }, + }, + { + name: "global source without selection", + selection: {}, + resolution: { domain_origin: "global", domain: "global.test" }, + }, + { + name: "global value mismatch", + selection: { globalDomain: "global.test" }, + resolution: { domain_origin: "global", domain: "foreign.test" }, + }, + { + name: "default value mismatch", + selection: {}, + resolution: { domain_origin: "default", domain: "foreign.test" }, + }, +])("binds domain source metadata to the request: $name", async ({ + selection, + resolution, +}) => { + const binary = await fixture({ response: withResolution(resolution) }); + await expectFailure({ + operation: resolveNativeConfig({ input: INPUT, binary, ...selection }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test("strips raw envelope fields while retaining the validated routing projection", async () => { + const binary = await fixture({ + response: { ...SUCCESS, private_payload: CANARY }, + }); + const result = await resolveNativeConfig({ input: INPUT, binary }); + const { environment_plan, ...expected } = SUCCESS; + void environment_plan; + expect(result).toEqual(expected); + expect(result).not.toHaveProperty("envelope"); + expect(JSON.stringify(result)).not.toContain(CANARY); +}); + +test("projects fixed Rust domain diagnostics without raw values or private pointer fields", async () => { + const diagnostic = { + document: "request", + code: "invalid_domain", + pointer: "/explicit_domain", + message: "Domain must be a canonical hostname.", + line: 1, + column: 1, + } as const; + const expected = { + transport_version: 1, + ok: false, + diagnostics: [diagnostic], + } as const; + const binary = await fixture({ + response: { + ...expected, + diagnostics: [{ ...diagnostic, value: CANARY, path: directory }], + private_payload: CANARY, + }, + exitCode: 1, + }); + const result = await resolveNativeConfig({ + input: INPUT, + binary, + requireRoutingPlanning: true, + explicitDomain: CANARY, + }); + expect(result).toEqual(expected); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(JSON.stringify(result)).not.toContain(directory); +}); + +test.each([ + { name: "single label", domain: "private" }, + { name: "numeric suffix", domain: "private.123" }, + { name: "hexadecimal suffix", domain: "private.0x1a" }, + { name: "empty hexadecimal suffix", domain: "private.0x" }, +])("refuses a noncanonical declared or reported domain: $name", async ({ + domain, +}) => { + for (const response of [ + withRoutesPlan({ domain }), + withRoutesPlan({ + aliases: { + ...ROUTES_PLAN.aliases, + oauth: { domain }, + }, + }), + ]) { + const binary = await fixture({ response }); + await expectFailure({ + operation: compileNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); + } + const binary = await fixture({ response: withResolution({ domain }) }); + await expectFailure({ + operation: resolveNativeConfig({ input: INPUT, binary }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test("retains the supported hack single-label domain", async () => { + const binary = await fixture({ + response: withRoutesPlan({ domain: "hack" }), + }); + const result = await compileNativeConfig({ input: INPUT, binary }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("Expected the supported hack domain"); + } + expect(result.plan.routes).toEqual({ ...ROUTES_PLAN, domain: "hack" }); +}); diff --git a/tests/native-routing-project.test.ts b/tests/native-routing-project.test.ts new file mode 100644 index 000000000..767bc3b9b --- /dev/null +++ b/tests/native-routing-project.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + planNativeProject, + validateNativeProject, +} from "../src/lib/native-project-validation.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const PROTOCOL = { + transport_version: 1, + authored_version: 1, + plan_version: 1, + resolve_version: 1, + local_version: 1, + env_plan_version: 1, + routing_plan_version: 1, +}; +const CANARY = "private-global-routing-canary"; +const KEYS = [ + "HACK_HOME", + "HACK_GLOBAL_CONFIG_PATH", + "HACK_CONFIG_COMPILER_BINARY", + "CI", + "HACK_EXECUTION_MODE", +] as const; +let root: string; +let saved: Record; +let projectRoot: string; +let receipt: string; +beforeEach(async () => { + saved = Object.fromEntries(KEYS.map((key) => [key, process.env[key]])); + for (const key of KEYS) { + Reflect.deleteProperty(process.env, key); + } + root = await realpath( + await mkdtemp(join(tmpdir(), "native-routing-project-")) + ); + projectRoot = join(root, "project"); + await mkdir(join(projectRoot, ".hack"), { recursive: true }); + await writeFile( + join(projectRoot, ".hack/hack.project.json"), + '{"schema_version":1,"name":"fixture"}' + ); + process.env.HACK_HOME = join(root, "home"); + receipt = join(root, "requests.jsonl"); +}); +afterEach(async () => { + for (const key of KEYS) { + restoreEnv(key, saved[key]); + } + await rm(root, { recursive: true, force: true }); +}); +async function mockCompiler( + opts: { + readonly routing?: boolean; + readonly localRouting?: boolean; + readonly capability?: boolean; + } = {} +) { + const binary = join(root, "compiler"); + const protocol = + opts.capability === false + ? { ...PROTOCOL, routing_plan_version: undefined } + : PROTOCOL; + await writeFile( + binary, + `#!${process.execPath} +import { appendFile } from 'node:fs/promises'; +if (process.argv[2] === '--protocol') { console.log(${JSON.stringify(JSON.stringify(protocol))}); } +else { + const operation=process.argv[2]; const raw=await Bun.stdin.text(); + const request=operation==='compile'?{}:JSON.parse(raw); + await appendFile(${JSON.stringify(receipt)}, JSON.stringify({operation,request})+'\\n'); + const plan={plan_version:1,name:'fixture',services:{},jobs:{},worktree:{inherit_local:false,auto_branch:false}${opts.routing ? ",routes:{aliases:{},http:{}}" : ""}}; + const result={transport_version:1,ok:true,semantic_hash:'a'.repeat(64),declared_workloads:{},plan}; + if(operation!=='compile') { + result.local_resolution={overlay:null,origin:'project',auto_branch:false,inherit_local:false,resolution_hash:'b'.repeat(64)}; + if(${opts.routing || opts.localRouting ? "true" : "false"} || request.explicit_domain!==undefined) { + const domain=request.explicit_domain??request.global_domain??'hack.local'; + const origin='https://fixture.'+domain; + if(request.routing_probe===true) result.routing_inputs_required=true; + else result.routing_resolution={domain,domain_origin:request.explicit_domain!==undefined?'explicit':request.global_domain!==undefined?'global':'default',project_origin:origin,aliases:{},oauth_alias:null,open_preference:'auto',open_preference_origin:'default',open_origin:origin,routes:{}}; + } + } + if(operation==='plan') result.environment_plan={plan_version:1,overlay:null,overlay_exists:false,complete:true,workloads:{},warnings:[],diagnostics:[]}; + console.log(JSON.stringify(result)); +} +` + ); + await chmod(binary, 0o700); + process.env.HACK_CONFIG_COMPILER_BINARY = binary; + return binary; +} +async function globalPolicy( + text = JSON.stringify({ default_domain: "global.invalid", secret: CANARY }) +) { + await mkdir(join(root, "home"), { recursive: true }); + await writeFile(join(root, "home/hack.config.json"), text); +} +async function failure(operation: Promise, code: string) { + const error: unknown = await operation.catch((value: unknown) => value); + expect(error).toMatchObject({ code }); + expect(String(error)).not.toContain(CANARY); +} +async function requests() { + return (await readFile(receipt, "utf8")) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); +} + +test("explicit domain and global scalar reach resolve and plan identically without unrelated global values", async () => { + await mockCompiler({ routing: true }); + await globalPolicy(); + const result = await planNativeProject({ + startDir: projectRoot, + explicitDomain: "explicit.invalid", + }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("Expected planning success"); + } + expect(result.routing_resolution?.domain).toBe("explicit.invalid"); + const calls = await requests(); + const resolved = calls.filter((call) => call.operation === "resolve"); + expect(resolved).toHaveLength(2); + expect(resolved[0].request.routing_probe).toBe(true); + expect(resolved[0].request).not.toHaveProperty("global_domain"); + expect(resolved[1].request).not.toHaveProperty("routing_probe"); + expect(resolved[1].request.global_domain).toBe("global.invalid"); + const planned = calls.find((call) => call.operation === "plan"); + expect(planned.request.explicit_domain).toBe("explicit.invalid"); + expect(planned.request.global_domain).toBe(resolved[1].request.global_domain); + expect(JSON.stringify(calls)).not.toContain(CANARY); +}); + +test("validation never reads managed YAML and nonrouting validation never reads global policy", async () => { + await mockCompiler(); + await globalPolicy(`broken ${CANARY}`); + await writeFile( + join(projectRoot, ".hack/hack.env.default.yaml"), + `broken [${CANARY}` + ); + expect((await validateNativeProject({ startDir: projectRoot })).ok).toBe( + true + ); + await mockCompiler({ routing: true }); + await globalPolicy(); + expect((await validateNativeProject({ startDir: projectRoot })).ok).toBe( + true + ); +}); + +test("routing global failures are refused rather than replaced by defaults", async () => { + await mockCompiler({ routing: true }); + await globalPolicy(`broken ${CANARY}`); + await failure( + validateNativeProject({ startDir: projectRoot }), + "E_CONFIG_INPUT" + ); + expect( + (await requests()).filter((call) => call.operation === "resolve") + ).toHaveLength(1); +}); + +test("explicit routing capability is fenced before compile or local policy acquisition", async () => { + await mockCompiler({ capability: false }); + await mkdir(join(projectRoot, ".hack/hack.local.json")); + await failure( + validateNativeProject({ + startDir: projectRoot, + explicitDomain: "example.invalid", + }), + "E_COMPILER_VERSION" + ); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +test("authored routing capability is fenced before local/global acquisition", async () => { + await mockCompiler({ capability: false, routing: true }); + await mkdir(join(projectRoot, ".hack/hack.local.json")); + await globalPolicy(`broken ${CANARY}`); + await failure( + validateNativeProject({ startDir: projectRoot }), + "E_COMPILER_VERSION" + ); + expect((await requests()).map((call) => call.operation)).toEqual(["compile"]); +}); + +test("local-only routing capability is fenced before global policy and managed metadata", async () => { + await mockCompiler({ capability: false, localRouting: true }); + await globalPolicy(`broken ${CANARY}`); + await writeFile( + join(projectRoot, ".hack/hack.env.default.yaml"), + `broken [${CANARY}` + ); + await failure( + planNativeProject({ startDir: projectRoot }), + "E_COMPILER_VERSION" + ); + expect((await requests()).map((call) => call.operation)).toEqual([ + "compile", + "resolve", + ]); +}); + +async function cli(args: readonly string[]) { + const child = Bun.spawn( + [ + process.execPath, + resolve(import.meta.dir, "../index.ts"), + "config", + ...args, + ], + { + cwd: projectRoot, + env: { ...process.env, HACK_LOGGER: "console" }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exit }; +} + +test("both native CLI commands forward --domain; explicit-file mode refuses it before any compiler read", async () => { + await mockCompiler(); + await globalPolicy(); + for (const operation of ["validate", "plan"]) { + const output = await cli([operation, "--domain", "cli.invalid", "--json"]); + expect(output.exit).toBe(0); + expect(JSON.parse(output.stdout).routing_resolution.domain).toBe( + "cli.invalid" + ); + } + for (const operation of ["validate", "plan"]) { + const human = await cli([operation, "--domain", "cli.invalid"]); + expect(human.exit).toBe(0); + expect(human.stdout).toContain( + "Routing preview: https://fixture.cli.invalid" + ); + expect(human.stdout).toContain("DNS and TLS are not checked"); + } + await rm(receipt); + for (const operation of ["validate", "plan"]) { + const output = await cli([ + operation, + "--file", + CANARY, + "--domain", + "cli.invalid", + ]); + expect(output.exit).not.toBe(0); + expect(await Bun.file(receipt).exists()).toBe(false); + } +});