diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bf702cb2..d4a33b719 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,9 @@ jobs: bun run --cwd packages/cli typecheck bun run build bun scripts/check-config-compiler-cli.ts + bunx ultracite check scripts/check-native-env-plan-cli.ts tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts + bun test tests/native-env-plan-transport.test.ts tests/native-env-metadata.test.ts tests/project-env-config.test.ts tests/project-env-metadata.test.ts + bun scripts/check-native-env-plan-cli.ts state-models: name: Runtime state models diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 09807dacb..968f4ae12 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -505,6 +505,7 @@ hack config [options] | `hack config get ` | Read a value from hack.config.json | | `hack config set ` | Update a value in hack.config.json | | `hack config validate` | Validate native configuration and selected local overlays without starting workloads | +| `hack config plan` | Inspect native environment binding completeness without decrypting or starting workloads | ### Options @@ -594,6 +595,30 @@ Uses the matching bundled Rust compiler. Without --file, discovers a native proj | `--help, -h` | Show help | | `--version, -v` | Show version | +## `hack config plan` + +Inspect native environment binding completeness without decrypting or starting workloads + +### Usage + +```bash +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. + +### Options + +| Option | Description | +| --- | --- | +| `--profile ` | Comma-separated declared native profiles | +| `--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) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + ## `hack session` diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index 42e083c4d..58f58b28b 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -72,8 +72,9 @@ versions refuse; omitted fields retain their documented defaults. destination names match `[A-Za-z_][A-Za-z0-9_]*`; managed `env_ref` names match the owning store's `[A-Z_][A-Z0-9_]*`. Public literals are authored configuration; never copy secrets into them. The compiler never reads managed stores or process - environment. Required references stay symbolic; managed-layer selection, missing - keys, remapping collisions and secret delivery remain later owner/admission checks. + environment. Required references stay symbolic during validation. Explicit + metadata planning checks managed-layer selection, missing keys and remapping + collisions; secret delivery and runtime admission remain separate steps. - Project `environment.default_overlay` may select a canonical named overlay; omission selects base. Names must already match `[a-z0-9]+(?:-[a-z0-9]+)*`; noncanonical spellings refuse rather than selecting a normalized different name. @@ -137,13 +138,14 @@ 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}`. - The CLI requires the two new capabilities for project-aware validation; an older - compiler can still serve explicit-file validation. + `{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_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. - `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. -- Success exits 0 and emits `{transport_version:1,ok:true,plan,semantic_hash}`. +- Success exits 0 and emits `{transport_version:1,ok:true,plan,semantic_hash,declared_workloads}`. The plan declares `plan_version:1`. Failure exits 1 and emits `{transport_version:1,ok:false,diagnostics:[...]}`. One deterministic first diagnostic contains a stable code, fixed redacted message, JSON pointer and @@ -161,6 +163,52 @@ diagnostic redaction does not turn the plan into a secret-safe storage format. `hack.project.schema.json`, `hack.local.schema.json` (2020-12) and `native-config.ts` projections. +## Explicit managed-env metadata planning + +```sh +hack config plan --json +hack config plan --path /path/to/native-project --env qa --profile dev --json +hack config plan --env base +``` + +This project-aware command uses the same validated local selection as `config +validate`, then asks the existing managed-env owner for effective key names, +winning scopes and secret flags. It parses selected managed YAML files internally; +it does not read the secret key, decrypt values, run hooks, touch the registry or +start workloads. `config validate` continues to perform no managed-document reads. +`config plan` has no `--file` mode. + +The owner merges tracked base/overlay, verified primary local base/overlay and +current checkout local base/overlay in that order. Later layers win before scope +precedence is applied. Null tombstones remove a key; an empty string is present. +CI, slim mode and `inherit_local:false` exclude primary inheritance. Unreadable, +redirected or invalid selected files refuse with fixed redacted errors. Missing +optional files are allowed. A missing selected named overlay keeps base/local +fallback and produces `missing_overlay`; unknown stored scopes produce +`inactive_env_scope` and grant no target authority. Services and jobs, including +inactive declarations, share one namespace. A workload named `host` owns that +scope; generic host overrides are otherwise excluded from guest bindings. + +The Rust planner binds `env_ref` against each workload's immutable managed baseline, +before authored directives. Literals replace bindings, defaults apply only when +the destination is absent, and unset removes it. Remapping into a different +already-bound managed destination refuses with `env_reference_collision`. +Unresolved required refs produce `missing_env_reference`. These failures yield +`ok:true`, `environment_plan.complete:false` and exit 1; a complete report exits 0. +Malformed inputs yield `ok:false` and exit 1. + +The report contains symbolic managed bindings and authored public literal/default +text, never managed values, ciphertext, key material or private file paths. It +leaves the authored `semantic_hash` and local `resolution_hash` unchanged. This is +binding completeness, not runtime admission, secret delivery or an atomic snapshot. + +The sidecar `plan` operation accepts the original `resolve` request fields plus +`env_metadata:{metadata_version:1,overlay:null|string,overlay_exists:boolean, +workloads:{NAME:{KEY:{scope:string,secret:boolean}}},inactive_scopes:string[]}`. +Every declared workload must appear, including inactive ones. Unknown fields, +target names, invalid scopes and mismatched selections refuse. Serialized metadata +is limited to 1 MiB in addition to the existing document and encoded-request bounds. + ## Local settings and worktrees The optional `.hack/hack.local.json` is a separate versioned document: diff --git a/packages/config-compiler/README.md b/packages/config-compiler/README.md index 30ee7e208..9ae96d9c0 100644 --- a/packages/config-compiler/README.md +++ b/packages/config-compiler/README.md @@ -81,3 +81,64 @@ cargo +1.97.1 fmt --manifest-path packages/config-compiler/Cargo.toml --check cargo +1.97.1 clippy --locked --manifest-path packages/config-compiler/Cargo.toml --all-targets -- -D warnings cargo +1.97.1 test --locked --manifest-path packages/config-compiler/Cargo.toml ``` + +## Metadata-only environment planning + +`compile` and `resolve` return a separate `declared_workloads` map from every +validated service/job name to `service` or `job`, including inactive profiles. +This projection does not change the authored plan or semantic hash. Consumers use +it to ask the existing environment owner for effective metadata without parsing +authored project JSON themselves. + +The protocol advertises `env_plan_version: 1`. `plan [--profile NAME]...` accepts +the resolve request fields plus required `env_metadata`: + +```json +{ + "metadata_version": 1, + "overlay": null, + "overlay_exists": false, + "workloads": { + "web": { "TOKEN": { "scope": "global", "secret": true } } + }, + "inactive_scopes": [] +} +``` + +`overlay` is required and nullable. It must equal the resolved selection; base +uses `overlay_exists: false`. Metadata must include exactly the complete declared +workload namespace. Each winning scope must be `global` or that owning workload; +managed key names use `[A-Z_][A-Z0-9_]*`. A workload named `host` owns its own +metadata; it does not grant generic host scope to other workloads. Inactive scopes +must be distinct valid stored scope names outside the declared namespace, excluding +reserved `global` and `host`. All supplied metadata is validated, including inactive +workloads. Unknown fields, arrays used as objects, and duplicates refuse. The JSON +serialization of metadata is limited to 1 MiB, in addition to the existing document +and encoded-request bounds. Managed values, ciphertext and paths are unsupported. + +Successful parsing returns `ok: true` and the unchanged plan, semantic hash, local +resolution and declared namespace, plus `environment_plan`. That object contains +`plan_version: 1`, selection metadata, `complete`, selected `workloads`, `warnings` +and `diagnostics`. Bindings have one of three forms: + +- `managed`: symbolic `key`, winning `scope`, and `secret` flag; +- `literal`: the explicit public authored `value`; +- `default`: the explicit public fallback `value` used when no baseline key exists. + +Managed baseline presence includes an empty managed value. `unset` removes a +binding. References always read the immutable owning baseline before authored +changes; a literal cannot create a reference source, nor can unset remove one. +Same-key references are allowed. A reference remapped onto a different existing +managed destination produces `env_reference_collision`; a missing source produces +`missing_env_reference`. Both make `complete: false`. Only selected workloads +contribute binding diagnostics. Missing selected overlays and inactive scopes emit +visible fixed warnings without authorizing new targets or suppressing missing refs. +Binding diagnostics use original project pointers and positions, without managed +values. Missing-overlay warnings identify the document that supplied the winning +selection: project, primary local, checkout local, or explicit request, preserving +that document’s pointer and line/column. + +The process exits `1` for incomplete bindings even when `ok: true`, and `0` only +for a complete metadata plan. Completeness is not runtime admission: this operation +cannot decrypt, verify secret availability, apply config, run hooks, or start a +workload. No metadata-derived or secret-derived hash is added to the portable plan. diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts index 224eabda8..01f824ea3 100644 --- a/packages/config-compiler/generated/native-config.ts +++ b/packages/config-compiler/generated/native-config.ts @@ -1,4 +1,10 @@ // Generated by hack-config-compiler; do not edit. +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, }; +export type EnvPlanRequest = { env_metadata: EnvMetadata, request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, }; +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 } }, warnings: Array, diagnostics: Array, }; +export type PlanResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, declared_workloads: { [key in string]: WorkloadKind }, local_resolution: LocalResolution, environment_plan: EnvironmentPlan, } | { transport_version: 1, ok: false, diagnostics: Array, }; export type WorktreePolicy = { auto_branch?: boolean, inherit_local?: boolean, }; export type SourceMode = "host-mounted"; export type Source = { root?: string, mode?: SourceMode, }; @@ -20,7 +26,8 @@ export type Workload = { image?: string, build?: Build, command?: Command, worki 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, }; export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, worktree: WorktreePolicy, 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 CompileResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, } | { transport_version: 1, ok: false, diagnostics: Array, }; +export type WorkloadKind = "service" | "job"; +export type CompileResult = { transport_version: 1, ok: true, plan: Plan, declared_workloads: { [key in string]: WorkloadKind }, 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, }; @@ -28,4 +35,4 @@ export type DocumentRole = "project" | "primary_local" | "checkout_local" | "req 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, 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 }, local_resolution: LocalResolution, } | { transport_version: 1, ok: false, diagnostics: Array, }; diff --git a/packages/config-compiler/src/environment.rs b/packages/config-compiler/src/environment.rs new file mode 100644 index 000000000..ce7de12cf --- /dev/null +++ b/packages/config-compiler/src/environment.rs @@ -0,0 +1,317 @@ +//! Metadata-only environment planning. Managed values, decryption keys and decryption are outside this boundary. +use crate::{ + WorkloadKind, diagnostic_at, json, local, + model::{EnvironmentValue, Plan}, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; +use ts_rs::TS; + +fn nullable<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Option::::deserialize(d) +} +#[derive(Debug, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct ManagedBindingMetadata { + pub scope: String, + pub secret: bool, +} +#[derive(Debug, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct EnvMetadata { + #[ts(type = "1")] + pub metadata_version: u32, + #[serde(deserialize_with = "nullable")] + pub overlay: Option, + pub overlay_exists: bool, + pub workloads: BTreeMap>, + pub inactive_scopes: Vec, +} +/// 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, + pub env_metadata: EnvMetadata, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum EnvironmentBinding { + Managed { + key: String, + scope: String, + secret: bool, + }, + Literal { + value: String, + }, + Default { + value: String, + }, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +pub struct EnvironmentPlan { + #[ts(type = "1")] + pub plan_version: u32, + pub overlay: Option, + pub overlay_exists: bool, + pub complete: bool, + pub workloads: BTreeMap>, + pub warnings: Vec, + pub diagnostics: Vec, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +#[serde(untagged)] +pub enum PlanResult { + Success { + #[ts(type = "1")] + transport_version: u32, + #[ts(type = "true")] + ok: bool, + plan: Box, + semantic_hash: String, + declared_workloads: BTreeMap, + local_resolution: local::LocalResolution, + environment_plan: EnvironmentPlan, + }, + Failure { + #[ts(type = "1")] + transport_version: u32, + #[ts(type = "false")] + ok: bool, + diagnostics: Vec, + }, +} +impl PlanResult { + pub fn failure(diagnostic: local::ResolveDiagnostic) -> Self { + Self::Failure { + transport_version: 1, + ok: false, + diagnostics: vec![diagnostic], + } + } + pub fn complete(&self) -> bool { + matches!(self, Self::Success {environment_plan, ..} if environment_plan.complete) + } +} +/// Plans symbolic bindings from original documents and effective owner metadata only. +pub fn plan(bytes: &[u8], profiles: &[String]) -> PlanResult { + match plan_inner(bytes, profiles) { + Ok((resolved, environment_plan)) => PlanResult::Success { + transport_version: 1, + ok: true, + plan: Box::new(resolved.compiled.plan), + semantic_hash: resolved.compiled.semantic_hash, + declared_workloads: resolved.compiled.declared_workloads, + local_resolution: resolved.local_resolution, + environment_plan, + }, + Err(error) => PlanResult::failure(error), + } +} +fn metadata_error(document: &json::Document, code: &str) -> local::ResolveDiagnostic { + local::with_role( + local::DocumentRole::Request, + diagnostic_at(&document.positions, code, "/env_metadata"), + ) +} +fn read_metadata(document: &mut json::Document) -> Result { + crate::shape::object(document, "/env_metadata") + .map_err(|_| metadata_error(document, "invalid_shape"))?; + let value = &document.value["env_metadata"]; + if serde_json::to_vec(value) + .map_err(|_| metadata_error(document, "encoding_failed"))? + .len() + > json::MAX_INPUT_BYTES + { + return Err(metadata_error(document, "input_too_large")); + } + if value + .get("metadata_version") + .is_some_and(|v| v.is_u64() && v.as_u64() != Some(1)) + { + return Err(metadata_error(document, "unsupported_metadata_version")); + } + if let Some(workloads) = value + .get("workloads") + .and_then(serde_json::Value::as_object) + { + for bindings in workloads.values() { + let Some(bindings) = bindings.as_object() else { + return Err(metadata_error(document, "invalid_shape")); + }; + if bindings.values().any(|binding| !binding.is_object()) { + return Err(metadata_error(document, "invalid_shape")); + } + } + } + let metadata: EnvMetadata = serde_json::from_value(value.clone()) + .map_err(|_| metadata_error(document, "invalid_metadata"))?; + // Remove the one additional field; the unchanged resolve decoder rejects every other unknown field. + if let Some(object) = document.value.as_object_mut() { + object.remove("env_metadata"); + } + Ok(metadata) +} +fn valid_scope(name: &str) -> bool { + !name.is_empty() + && name.bytes().enumerate().all(|(i, b)| { + b.is_ascii_lowercase() + || b.is_ascii_digit() + || (i > 0 && matches!(b, b'.' | b'_' | b'-')) + }) +} +fn valid_key(name: &str) -> bool { + !name.is_empty() + && name + .bytes() + .enumerate() + .all(|(i, b)| b.is_ascii_uppercase() || b == b'_' || (i > 0 && b.is_ascii_digit())) +} +fn validate_metadata(metadata: &EnvMetadata, resolved: &local::Resolved) -> bool { + if metadata.overlay != resolved.local_resolution.overlay + || (metadata.overlay.is_none() && metadata.overlay_exists) + || metadata + .workloads + .keys() + .ne(resolved.compiled.declared_workloads.keys()) + { + return false; + } + for (name, bindings) in &metadata.workloads { + if bindings + .iter() + .any(|(key, b)| !valid_key(key) || (b.scope != "global" && b.scope != *name)) + { + return false; + } + } + let mut seen = BTreeSet::new(); + !metadata.inactive_scopes.iter().any(|scope| { + !valid_scope(scope) + || matches!(scope.as_str(), "global" | "host") + || resolved.compiled.declared_workloads.contains_key(scope) + || !seen.insert(scope) + }) +} +fn plan_inner( + bytes: &[u8], + profiles: &[String], +) -> 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))?; + let metadata = read_metadata(&mut document)?; + let metadata_location = diagnostic_at(&document.positions, "invalid_metadata", "/env_metadata"); + let resolved = local::resolve_document(document, profiles)?; + if !validate_metadata(&metadata, &resolved) { + return Err(local::with_role( + local::DocumentRole::Request, + metadata_location, + )); + } + let output = bind(&resolved, metadata); + Ok((resolved, output)) +} +fn project_diagnostic( + resolved: &local::Resolved, + code: &str, + pointer: &str, +) -> local::ResolveDiagnostic { + local::with_role( + local::DocumentRole::Project, + diagnostic_at(&resolved.compiled.positions, code, pointer), + ) +} +fn managed(key: &str, binding: &ManagedBindingMetadata) -> EnvironmentBinding { + EnvironmentBinding::Managed { + key: key.into(), + scope: binding.scope.clone(), + secret: binding.secret, + } +} +fn bind(resolved: &local::Resolved, metadata: EnvMetadata) -> EnvironmentPlan { + let mut output = EnvironmentPlan { + plan_version: 1, + overlay: metadata.overlay.clone(), + overlay_exists: metadata.overlay_exists, + complete: true, + workloads: BTreeMap::new(), + warnings: Vec::new(), + diagnostics: Vec::new(), + }; + if metadata.overlay.is_some() && !metadata.overlay_exists { + output.warnings.push(resolved.overlay_location.clone()); + } + if !metadata.inactive_scopes.is_empty() { + output + .warnings + .push(project_diagnostic(resolved, "inactive_env_scope", "")); + } + for (kind, workloads) in [ + ("services", &resolved.compiled.plan.services), + ("jobs", &resolved.compiled.plan.jobs), + ] { + for (name, workload) in workloads { + // Exact metadata namespace equality was established before reaching this loop. + let Some(baseline) = metadata.workloads.get(name) else { + continue; + }; + let mut bindings: BTreeMap = baseline + .iter() + .map(|(key, b)| (key.clone(), managed(key, b))) + .collect(); + for (dest, directive) in &workload.environment { + let pointer = json::child( + &format!("{}/environment", json::child(&format!("/{kind}"), name)), + dest, + ); + match directive { + EnvironmentValue::Literal { literal } => { + bindings.insert( + dest.clone(), + EnvironmentBinding::Literal { + value: literal.clone(), + }, + ); + } + EnvironmentValue::Default { default } => { + if !baseline.contains_key(dest) { + bindings.insert( + dest.clone(), + EnvironmentBinding::Default { + value: default.clone(), + }, + ); + } + } + EnvironmentValue::Unset { .. } => { + bindings.remove(dest); + } + EnvironmentValue::Reference { env_ref } => { + if let Some(binding) = baseline.get(env_ref) { + if env_ref != dest && baseline.contains_key(dest) { + output.diagnostics.push(project_diagnostic( + resolved, + "env_reference_collision", + &pointer, + )); + } else { + bindings.insert(dest.clone(), managed(env_ref, binding)); + } + } else { + output.diagnostics.push(project_diagnostic( + resolved, + "missing_env_reference", + &pointer, + )); + } + } + } + } + output.workloads.insert(name.clone(), bindings); + } + } + output.complete = output.diagnostics.is_empty(); + output +} diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs index 1a7982b46..9874af923 100644 --- a/packages/config-compiler/src/lib.rs +++ b/packages/config-compiler/src/lib.rs @@ -1,4 +1,5 @@ //! Pure, bounded native configuration compiler. It performs no host admission or secret lookup. +pub mod environment; mod json; pub mod local; pub mod model; @@ -34,6 +35,20 @@ impl Diagnostic { } fn diagnostic_message(code: &str) -> &'static str { match code { + "unsupported_metadata_version" => "The environment metadata version is not supported.", + "invalid_metadata" => { + "Environment metadata does not match the declared workload selection." + } + "missing_env_reference" => "A required managed environment reference is missing.", + "env_reference_collision" => { + "A remapped reference conflicts with an existing managed destination." + } + "missing_overlay" => { + "The selected overlay is missing; available base and local layers are used." + } + "inactive_env_scope" => { + "Stored environment scopes outside the declared workload namespace are inactive." + } "unsupported_request_version" => "The resolution request version is not supported.", "input_too_large" => "Configuration exceeds the compiler input byte limit.", "invalid_utf8" => "Configuration must be UTF-8.", @@ -80,7 +95,8 @@ pub enum CompileResult { transport_version: u32, #[ts(type = "true")] ok: bool, - plan: Plan, + plan: Box, + declared_workloads: BTreeMap, semantic_hash: String, }, Failure { @@ -104,16 +120,29 @@ impl CompileResult { /// Compiles explicit input and profile names only. Environment directives stay symbolic. pub fn compile(bytes: &[u8], profiles: &[String]) -> CompileResult { match compile_inner(bytes, profiles) { - Ok((plan, semantic_hash)) => CompileResult::Success { + Ok(compiled) => CompileResult::Success { transport_version: 1, ok: true, - plan, - semantic_hash, + plan: Box::new(compiled.plan), + declared_workloads: compiled.declared_workloads, + semantic_hash: compiled.semantic_hash, }, Err(error) => CompileResult::failure(error), } } -fn compile_inner(bytes: &[u8], profiles: &[String]) -> Result<(Plan, String), Diagnostic> { +#[derive(Debug, Clone, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkloadKind { + Service, + Job, +} +struct Compiled { + plan: Plan, + semantic_hash: String, + declared_workloads: BTreeMap, + positions: BTreeMap, +} +fn compile_inner(bytes: &[u8], profiles: &[String]) -> Result { let document = json::parse(bytes)?; let at = |code: &str, pointer: &str| diagnostic_at(&document.positions, code, pointer); if let Some(version) = document.value.get("schema_version") @@ -146,10 +175,26 @@ fn compile_inner(bytes: &[u8], profiles: &[String]) -> Result<(Plan, String), Di }; at(code, &pointer) })?; + let declared_workloads = project + .services + .keys() + .map(|name| (name.clone(), WorkloadKind::Service)) + .chain( + project + .jobs + .keys() + .map(|name| (name.clone(), WorkloadKind::Job)), + ) + .collect(); let plan = validate::lower(project, profiles, &at)?; let encoded = serde_json::to_vec(&plan).map_err(|_| at("encoding_failed", ""))?; let semantic_hash = format!("{:x}", Sha256::digest(encoded)); - Ok((plan, semantic_hash)) + Ok(Compiled { + plan, + semantic_hash, + declared_workloads, + positions: document.positions, + }) } fn diagnostic_at( positions: &BTreeMap, @@ -181,6 +226,12 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { let schema = serde_json::to_string_pretty(&schema)? + "\n"; let cfg = ts_rs::Config::default(); let declarations = [ + environment::ManagedBindingMetadata::decl(&cfg), + environment::EnvMetadata::decl(&cfg), + environment::EnvPlanRequest::decl(&cfg), + environment::EnvironmentBinding::decl(&cfg), + environment::EnvironmentPlan::decl(&cfg), + environment::PlanResult::decl(&cfg), WorktreePolicy::decl(&cfg), SourceMode::decl(&cfg), Source::decl(&cfg), @@ -202,6 +253,7 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { Project::decl(&cfg), Plan::decl(&cfg), Diagnostic::decl(&cfg), + WorkloadKind::decl(&cfg), CompileResult::decl(&cfg), local::LocalEnvironment::decl(&cfg), local::LocalConfig::decl(&cfg), @@ -226,5 +278,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}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1}) } diff --git a/packages/config-compiler/src/local.rs b/packages/config-compiler/src/local.rs index a09ece800..536cae0e6 100644 --- a/packages/config-compiler/src/local.rs +++ b/packages/config-compiler/src/local.rs @@ -80,7 +80,7 @@ pub enum DocumentRole { CheckoutLocal, Request, } -#[derive(Debug, Serialize, JsonSchema, TS)] +#[derive(Debug, Clone, Serialize, JsonSchema, TS)] pub struct ResolveDiagnostic { pub document: DocumentRole, #[serde(flatten)] @@ -112,6 +112,7 @@ pub enum ResolveResult { ok: bool, plan: Box, semantic_hash: String, + declared_workloads: std::collections::BTreeMap, local_resolution: LocalResolution, }, Failure { @@ -134,13 +135,13 @@ impl ResolveResult { } } } -fn with_role(document: DocumentRole, diagnostic: Diagnostic) -> ResolveDiagnostic { +pub(crate) fn with_role(document: DocumentRole, diagnostic: Diagnostic) -> ResolveDiagnostic { ResolveDiagnostic { document, diagnostic, } } -fn decode( +pub(crate) fn decode( document: &json::Document, role: DocumentRole, ) -> Result { @@ -166,7 +167,11 @@ fn decode( with_role(role, diagnostic_at(&document.positions, code, &pointer)) }) } -fn read_local(text: &str, role: DocumentRole) -> Result { +struct ParsedLocal { + config: LocalConfig, + overlay_location: ResolveDiagnostic, +} +fn read_local(text: &str, role: DocumentRole) -> Result { let document = json::parse(text.as_bytes()).map_err(|d| with_role(role, d))?; if document .value @@ -203,18 +208,29 @@ fn read_local(text: &str, role: DocumentRole) -> Result ResolveResult { match resolve_inner(bytes, profiles) { - Ok((plan, semantic_hash, local_resolution)) => ResolveResult::Success { + Ok(resolved) => ResolveResult::Success { transport_version: 1, ok: true, - plan: Box::new(plan), - semantic_hash, - local_resolution, + plan: Box::new(resolved.compiled.plan), + semantic_hash: resolved.compiled.semantic_hash, + declared_workloads: resolved.compiled.declared_workloads, + local_resolution: resolved.local_resolution, }, Err(diagnostic) => ResolveResult::Failure { transport_version: 1, @@ -223,12 +239,20 @@ pub fn resolve(bytes: &[u8], profiles: &[String]) -> ResolveResult { }, } } -fn resolve_inner( - bytes: &[u8], - profiles: &[String], -) -> Result<(Plan, String, LocalResolution), ResolveDiagnostic> { +pub(crate) struct Resolved { + pub(crate) compiled: crate::Compiled, + pub(crate) local_resolution: LocalResolution, + pub(crate) overlay_location: ResolveDiagnostic, +} +fn resolve_inner(bytes: &[u8], profiles: &[String]) -> Result { let document = json::parse_with_limit(bytes, MAX_REQUEST_BYTES) .map_err(|d| with_role(DocumentRole::Request, d))?; + resolve_document(document, profiles) +} +pub(crate) fn resolve_document( + document: json::Document, + profiles: &[String], +) -> Result { if document .value .get("request_version") @@ -282,8 +306,10 @@ fn resolve_inner( diagnostic_at(&document.positions, "invalid_name", "/explicit_overlay"), )); } - let (plan, semantic_hash) = compile_inner(request.project.as_bytes(), profiles) + let compiled = compile_inner(request.project.as_bytes(), profiles) .map_err(|d| with_role(DocumentRole::Project, d))?; + let plan = &compiled.plan; + let semantic_hash = &compiled.semantic_hash; // Even opted-out supplied primary input must be valid, and remains bound into the resolution generation. let primary = request .primary_local @@ -297,39 +323,51 @@ fn resolve_inner( .transpose()?; let mut overlay = plan.environment.default_overlay.clone(); let mut origin = OverlayOrigin::Project; + let mut overlay_location = with_role( + DocumentRole::Project, + diagnostic_at( + &compiled.positions, + "missing_overlay", + "/environment/default_overlay", + ), + ); if plan.worktree.inherit_local - && let Some(value) = primary - .as_ref() - .and_then(|v| v.environment.default_overlay.as_ref()) + && let Some(input) = &primary + && let Some(value) = &input.config.environment.default_overlay { overlay = value.clone(); origin = OverlayOrigin::PrimaryLocal; + overlay_location = input.overlay_location.clone(); } - if let Some(value) = checkout - .as_ref() - .and_then(|v| v.environment.default_overlay.as_ref()) + if let Some(input) = &checkout + && let Some(value) = &input.config.environment.default_overlay { overlay = value.clone(); origin = OverlayOrigin::CheckoutLocal; + overlay_location = input.overlay_location.clone(); } if let Some(value) = &request.explicit_overlay { overlay = value.clone(); origin = OverlayOrigin::Explicit; + overlay_location = with_role( + DocumentRole::Request, + diagnostic_at(&document.positions, "missing_overlay", "/explicit_overlay"), + ); } #[derive(Serialize)] struct ResolutionInputs<'a> { resolve_version: u32, semantic_hash: &'a str, - primary_local: &'a Option, - checkout_local: &'a Option, + primary_local: Option<&'a LocalConfig>, + checkout_local: Option<&'a LocalConfig>, #[serde(skip_serializing_if = "Option::is_none")] explicit_overlay: &'a Option>, } let encoded = serde_json::to_vec(&ResolutionInputs { resolve_version: 1, - semantic_hash: &semantic_hash, - primary_local: &primary, - checkout_local: &checkout, + semantic_hash, + primary_local: primary.as_ref().map(|input| &input.config), + checkout_local: checkout.as_ref().map(|input| &input.config), explicit_overlay: &request.explicit_overlay, }) .map_err(|_| { @@ -345,7 +383,11 @@ fn resolve_inner( inherit_local: plan.worktree.inherit_local, resolution_hash: format!("{:x}", Sha256::digest(encoded)), }; - Ok((plan, semantic_hash, local_resolution)) + Ok(Resolved { + compiled, + local_resolution, + overlay_location, + }) } pub fn local_schema() -> Result { diff --git a/packages/config-compiler/src/main.rs b/packages/config-compiler/src/main.rs index 3ae51a633..fd606298c 100644 --- a/packages/config-compiler/src/main.rs +++ b/packages/config-compiler/src/main.rs @@ -23,6 +23,35 @@ fn main() -> std::process::ExitCode { } } [command, directory] if command == "generate" => generate(Path::new(directory)), + [command, rest @ ..] if command == "plan" => { + let mut profiles = Vec::new(); + for pair in rest.chunks(2) { + if pair.len() != 2 || pair[0] != "--profile" { + return usage(); + } + profiles.push(pair[1].clone()); + } + let mut bytes = Vec::new(); + let result = if io::stdin() + .take((MAX_REQUEST_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .is_err() + { + hack_config_compiler::environment::PlanResult::failure( + hack_config_compiler::local::ResolveDiagnostic { + document: DocumentRole::Request, + diagnostic: Diagnostic::new("input_read_failed", "", 1, 1), + }, + ) + } else { + hack_config_compiler::environment::plan(&bytes, &profiles) + }; + if emit(&result).is_err() || !result.complete() { + 1 + } else { + 0 + } + } [command, rest @ ..] if command == "resolve" => { let mut profiles = Vec::new(); for pair in rest.chunks(2) { @@ -82,7 +111,7 @@ fn main() -> std::process::ExitCode { } fn usage() -> std::process::ExitCode { eprintln!( - "Usage: hack-config-compiler --protocol | compile [--profile NAME]... | resolve [--profile NAME]... | generate DIR" + "Usage: hack-config-compiler --protocol | compile [--profile NAME]... | resolve [--profile NAME]... | plan [--profile NAME]... | generate DIR" ); std::process::ExitCode::from(2) } diff --git a/packages/config-compiler/tests/compiler.rs b/packages/config-compiler/tests/compiler.rs index 8c0ca9fd7..66d09caff 100644 --- a/packages/config-compiler/tests/compiler.rs +++ b/packages/config-compiler/tests/compiler.rs @@ -219,6 +219,7 @@ fn schema_generation_is_deterministic_and_closed() { assert_eq!(v["$defs"]["Workload"]["additionalProperties"], false); assert!(dto.contains("env_ref")); assert!(dto.contains("plan_version: 1")); + assert_eq!(dto.matches("export type WorkloadKind =").count(), 1); } #[test] diff --git a/packages/config-compiler/tests/environment.rs b/packages/config-compiler/tests/environment.rs new file mode 100644 index 000000000..558b6a76e --- /dev/null +++ b/packages/config-compiler/tests/environment.rs @@ -0,0 +1,287 @@ +use hack_config_compiler::{compile, environment::plan, local::resolve}; +use serde_json::{Value, json}; +fn project() -> Value { + json!({"schema_version":1,"name":"example","profiles":["dev"],"services":{"web":{"image":"web:1"},"host":{"image":"host:1","profiles":["dev"],"environment":{"REQUIRED":{"env_ref":"ABSENT"}}}},"jobs":{"init":{"image":"init:1"}}}) +} +fn metadata() -> Value { + json!({"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"web":{},"host":{},"init":{}},"inactive_scopes":[]}) +} +fn request(p: Value, m: Value) -> Value { + json!({"request_version":1,"project":p.to_string(),"env_metadata":m}) +} +fn output(r: &Value, profiles: &[String]) -> Value { + serde_json::to_value(plan(r.to_string().as_bytes(), profiles)).unwrap() +} +fn entry(scope: &str, secret: bool) -> Value { + json!({"scope":scope,"secret":secret}) +} +fn reject(r: &Value) { + let v = output(r, &[]); + assert_eq!(v["ok"], false, "{v}"); + assert_eq!(v["diagnostics"][0]["document"], "request", "{v}"); +} +#[test] +fn declaration_projection_includes_jobs_inactive_and_host_without_changing_hashes() { + let p = project(); + let m = metadata(); + let r = request(p.clone(), m); + let planned = output(&r, &[]); + let compiled = serde_json::to_value(compile(p.to_string().as_bytes(), &[])).unwrap(); + let resolved = serde_json::to_value(resolve( + json!({"request_version":1,"project":p.to_string()}) + .to_string() + .as_bytes(), + &[], + )) + .unwrap(); + for result in [&compiled, &resolved, &planned] { + assert_eq!( + result["declared_workloads"], + json!({"web":"service","host":"service","init":"job"}) + ); + assert_eq!(result["semantic_hash"], compiled["semantic_hash"]); + assert_eq!(result["plan"], compiled["plan"]); + } + assert_eq!(planned["environment_plan"]["complete"], true); + assert!( + planned["environment_plan"]["workloads"] + .get("host") + .is_none() + ); + let selected = output(&r, &["dev".into()]); + assert_eq!(selected["ok"], true); + assert_eq!(selected["environment_plan"]["complete"], false); + assert_eq!( + selected["environment_plan"]["diagnostics"][0]["pointer"], + "/services/host/environment/REQUIRED" + ); +} +#[test] +fn directives_bind_immutable_baseline_and_preserve_secret_metadata() { + let mut p = project(); + p["services"]["web"]["environment"] = json!({"A":{"literal":"public"},"B":{"env_ref":"A"},"C":{"default":"fallback"},"D":{"default":"unused"},"E":{"unset":true},"SELF":{"env_ref":"SELF"}}); + let mut m = metadata(); + m["workloads"]["web"] = json!({"A":entry("global",true),"D":entry("web",false),"E":entry("web",true),"SELF":entry("web",true)}); + let out = output(&request(p, m), &[]); + assert_eq!(out["environment_plan"]["complete"], true); + let b = &out["environment_plan"]["workloads"]["web"]; + assert_eq!(b["A"], json!({"kind":"literal","value":"public"})); + assert_eq!( + b["B"], + json!({"kind":"managed","key":"A","scope":"global","secret":true}) + ); + assert_eq!(b["C"], json!({"kind":"default","value":"fallback"})); + assert_eq!(b["D"]["kind"], "managed"); + assert_eq!(b["SELF"]["key"], "SELF"); + assert!(b.get("E").is_none()); +} +#[test] +fn missing_references_and_remapped_managed_destinations_are_incomplete() { + let mut p = project(); + p["jobs"]["init"]["environment"] = + json!({"DEST":{"env_ref":"SOURCE"},"MISSING":{"env_ref":"NOPE"}}); + let mut m = metadata(); + m["workloads"]["init"] = json!({"DEST":entry("init",false),"SOURCE":entry("global",true)}); + let out = output(&request(p, m), &[]); + assert_eq!(out["ok"], true); + assert_eq!(out["environment_plan"]["complete"], false); + let d = &out["environment_plan"]["diagnostics"]; + assert_eq!(d[0]["code"], "env_reference_collision"); + assert_eq!(d[1]["code"], "missing_env_reference"); + assert_eq!(d[0]["document"], "project"); + assert!(d[0]["column"].as_u64().unwrap() > 1); +} +#[test] +fn unset_and_authored_literals_do_not_create_or_remove_reference_sources() { + let mut p = project(); + p["services"]["web"]["environment"] = json!({"A":{"unset":true},"B":{"env_ref":"A"},"C":{"literal":"public"},"D":{"env_ref":"C"}}); + let mut m = metadata(); + m["workloads"]["web"] = json!({"A":entry("web",true)}); + let out = output(&request(p, m), &[]); + assert_eq!(out["environment_plan"]["workloads"]["web"]["B"]["key"], "A"); + assert_eq!( + out["environment_plan"]["diagnostics"][0]["pointer"], + "/services/web/environment/D" + ); +} +#[test] +fn metadata_shape_namespace_scope_and_version_are_strict() { + for field in [ + "metadata_version", + "overlay", + "overlay_exists", + "workloads", + "inactive_scopes", + ] { + let mut m = metadata(); + m.as_object_mut().unwrap().remove(field); + reject(&request(project(), m)); + } + for value in [ + json!([]), + Value::Null, + json!({"metadata_version":2}), + json!({}), + ] { + reject(&request(project(), value)); + } + for value in [json!([]), Value::Null, json!("scope")] { + let mut m = metadata(); + m["workloads"]["web"] = value; + reject(&request(project(), m)); + } + for value in [ + json!([]), + Value::Null, + json!(["global", true]), + json!({"scope":"global","secret":true,"value":"private-sentinel"}), + json!({"scope":"init","secret":true}), + json!({"scope":"host","secret":true}), + json!({"scope":"global","secret":"true"}), + ] { + let mut m = metadata(); + m["workloads"]["web"]["KEY"] = value; + let r = request(project(), m); + reject(&r); + assert!(!output(&r, &[]).to_string().contains("private-sentinel")); + } + for scope in ["global", "host", "web", "bad scope"] { + let mut m = metadata(); + m["inactive_scopes"] = json!([scope]); + reject(&request(project(), m)); + } + let mut m = metadata(); + m["inactive_scopes"] = json!(["old", "old"]); + reject(&request(project(), m)); + let mut m = metadata(); + m["workloads"].as_object_mut().unwrap().remove("host"); + reject(&request(project(), m)); + let mut m = metadata(); + m["workloads"]["unknown"] = json!({}); + reject(&request(project(), m)); + let mut m = metadata(); + m["workloads"]["web"]["lowercase"] = entry("web", false); + reject(&request(project(), m)); + let mut m = metadata(); + m["overlay_exists"] = json!(true); + reject(&request(project(), m)); + let mut m = metadata(); + m["overlay"] = json!("qa"); + reject(&request(project(), m)); + let mut m = metadata(); + m["extra"] = json!("private-sentinel"); + reject(&request(project(), m)); +} +#[test] +fn inactive_metadata_is_validated_and_host_workload_owns_only_its_scope() { + let mut m = metadata(); + m["workloads"]["host"]["KEY"] = entry("host", true); + assert_eq!(output(&request(project(), m.clone()), &[])["ok"], true); + m["workloads"]["host"]["KEY"] = entry("web", true); + reject(&request(project(), m)); +} +#[test] +fn missing_overlay_and_inactive_scopes_warn_without_granting_authority() { + let mut p = project(); + p["environment"] = json!({"default_overlay":"qa"}); + let mut m = metadata(); + m["overlay"] = json!("qa"); + m["inactive_scopes"] = json!(["old"]); + m["workloads"]["web"]["KEY"] = entry("global", true); + let out = output(&request(p, m), &[]); + assert_eq!(out["environment_plan"]["complete"], true); + assert_eq!( + out["environment_plan"]["warnings"][0]["code"], + "missing_overlay" + ); + assert_eq!( + out["environment_plan"]["warnings"][1]["code"], + "inactive_env_scope" + ); + assert_eq!( + out["environment_plan"]["workloads"]["web"]["KEY"]["scope"], + "global" + ); +} +#[test] +fn metadata_does_not_change_authored_or_resolution_hashes() { + let r = request(project(), metadata()); + let first = output(&r, &[]); + let mut r = r; + r["env_metadata"]["workloads"]["web"]["KEY"] = entry("global", true); + let second = output(&r, &[]); + assert_eq!(first["semantic_hash"], second["semantic_hash"]); + assert_eq!(first["local_resolution"], second["local_resolution"]); + let pretty = serde_json::to_string_pretty(&r).unwrap(); + assert_eq!( + serde_json::to_value(plan(pretty.as_bytes(), &[])).unwrap(), + second + ); +} +#[test] +fn duplicates_and_limits_are_rejected_without_exposing_values() { + let r = request(project(), metadata()).to_string().replace( + "\"metadata_version\":1", + "\"metadata_version\":1,\"metadata_version\":1", + ); + let out = serde_json::to_value(plan(r.as_bytes(), &[])).unwrap(); + assert_eq!(out["diagnostics"][0]["code"], "duplicate_key"); + let mut m = metadata(); + m["inactive_scopes"] = json!(["a".repeat(hack_config_compiler::MAX_INPUT_BYTES)]); + let out = output(&request(project(), m), &[]); + assert_eq!(out["diagnostics"][0]["code"], "input_too_large"); + for field in ["project", "primary_local", "checkout_local"] { + let mut r = request(project(), metadata()); + r[field] = json!("{\"schema_version\":1,\"schema_version\":1}"); + let out = output(&r, &[]); + assert_eq!(out["diagnostics"][0]["document"], field); + assert_eq!(out["diagnostics"][0]["code"], "duplicate_key"); + } + let out = serde_json::to_value(plan( + &vec![b' '; hack_config_compiler::local::MAX_REQUEST_BYTES + 1], + &[], + )) + .unwrap(); + assert_eq!(out["diagnostics"][0]["code"], "input_too_large"); +} + +#[test] +fn missing_overlay_warning_tracks_local_and_explicit_selection_provenance() { + let local_text = "{\n \"schema_version\": 1,\n \"environment\": {\n \"default_overlay\": \"qa\"\n }\n}"; + let selection_line = local_text.lines().nth(3).unwrap(); + for role in ["primary_local", "checkout_local"] { + let mut m = metadata(); + m["overlay"] = json!("qa"); + let mut r = request(project(), m); + r[role] = json!(local_text); + let out = output(&r, &[]); + let warning = &out["environment_plan"]["warnings"][0]; + assert_eq!(warning["code"], "missing_overlay"); + assert_eq!(warning["document"], role); + assert_eq!(warning["pointer"], "/environment/default_overlay"); + assert_eq!(warning["line"], 4); + assert_eq!( + warning["column"], + selection_line.find("\"qa\"").unwrap() + 1 + ); + assert!(!warning["message"].as_str().unwrap().contains("qa")); + } + let mut m = metadata(); + m["overlay"] = json!("qa"); + let mut r = request(project(), m); + r["checkout_local"] = json!(local_text); + r["explicit_overlay"] = json!("qa"); + let text = serde_json::to_string_pretty(&r).unwrap(); + let (line, content) = text + .lines() + .enumerate() + .find(|(_, text)| text.contains("\"explicit_overlay\"")) + .unwrap(); + let out = serde_json::to_value(plan(text.as_bytes(), &[])).unwrap(); + let warning = &out["environment_plan"]["warnings"][0]; + assert_eq!(warning["code"], "missing_overlay"); + assert_eq!(warning["document"], "request"); + assert_eq!(warning["pointer"], "/explicit_overlay"); + assert_eq!(warning["line"], line + 1); + assert_eq!(warning["column"], content.find("\"qa\"").unwrap() + 1); +} diff --git a/packages/config-compiler/tests/protocol.rs b/packages/config-compiler/tests/protocol.rs index 25b95e727..ec55d446a 100644 --- a/packages/config-compiler/tests/protocol.rs +++ b/packages/config-compiler/tests/protocol.rs @@ -21,7 +21,7 @@ fn handshake_and_compile_need_no_environment_or_host_tools() { let protocol: Value = serde_json::from_slice(&handshake.stdout).unwrap(); assert_eq!( protocol, - serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1}) ); let result = run(&["compile"], br#"{"schema_version":1,"name":"example"}"#); assert!(result.status.success()); @@ -103,3 +103,30 @@ fn resolve_binary_preserves_profiles_and_reports_document_roles() { assert_eq!(value["diagnostics"][0]["code"], "duplicate_key"); assert_eq!(value["diagnostics"][0]["document"], "checkout_local"); } + +#[test] +fn environment_plan_process_separates_valid_documents_from_complete_bindings() { + let project = r#"{"schema_version":1,"name":"example","jobs":{"init":{"image":"init:1","environment":{"NEEDED":{"env_ref":"KEY"}}}}}"#; + let mut request = serde_json::json!({"request_version":1,"project":project,"env_metadata":{"metadata_version":1,"overlay":null,"overlay_exists":false,"workloads":{"init":{}},"inactive_scopes":[]}}); + let output = run(&["plan"], request.to_string().as_bytes()); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stderr.is_empty()); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["ok"], true); + assert_eq!(result["environment_plan"]["complete"], false); + request["env_metadata"]["workloads"]["init"]["KEY"] = + serde_json::json!({"scope":"init","secret":true}); + let output = run(&["plan"], request.to_string().as_bytes()); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["environment_plan"]["complete"], true); + let output = run(&["plan", "--unsupported", "private-sentinel"], b""); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!( + !String::from_utf8(output.stderr) + .unwrap() + .contains("private-sentinel") + ); +} diff --git a/scripts/check-native-env-plan-cli.ts b/scripts/check-native-env-plan-cli.ts new file mode 100644 index 000000000..f9cbb0281 --- /dev/null +++ b/scripts/check-native-env-plan-cli.ts @@ -0,0 +1,367 @@ +#!/usr/bin/env bun +import { + copyFile, + mkdir, + mkdtemp, + readdir, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; + +/** Real compiled CLI/sidecar, isolated HOME and Git worktrees, no source or toolchain on PATH. */ +const root = resolve(import.meta.dir, ".."); +const directory = await mkdtemp(join(tmpdir(), "hack-env-plan-cli-")); +const bundle = join(directory, "bundle"); +const home = join(directory, "home"); +const primary = join(directory, "primary"); +const sentinel = "private-synthetic-managed-value"; +const secret = { secure: `invalid-ciphertext-${sentinel}` }; +const project = { + schema_version: 1, + name: "env-plan-fixture", + profiles: ["dev"], + environment: { default_overlay: "qa" }, + services: { + web: { + image: "example/web:1", + environment: { + TOKEN: { env_ref: "TOKEN" }, + COPY: { env_ref: "SOURCE" }, + PUBLIC: { literal: "authored public text" }, + FALLBACK: { default: "authored fallback" }, + EMPTY: { default: "must not replace empty" }, + ERASE: { unset: true }, + ["__proto__"]: { literal: "authored own binding" }, + }, + }, + host: { image: "example/host:1" }, + optional: { + image: "example/optional:1", + profiles: ["dev"], + environment: { REQUIRED: { env_ref: "ABSENT" } }, + }, + }, + jobs: { + init: { + image: "example/init:1", + environment: { JOB: { env_ref: "JOB" } }, + }, + }, +}; +try { + await Promise.all([ + mkdir(bundle), + mkdir(home), + mkdir(join(primary, ".hack"), { recursive: true }), + ]); + for (const name of ["hack", "hack-config-compiler"]) { + await copyFile(join(root, "dist", name), join(bundle, name)); + } + const authored = join(primary, ".hack/hack.project.json"); + await Bun.write(authored, JSON.stringify(project)); + // These sentinels must never be opened by validation/planning. + await mkdir(join(primary, ".hack/.hack.secret.key")); + await Bun.write(join(primary, ".hack/.env"), `UNMANAGED=${sentinel}`); + const base = join(primary, ".hack/hack.env.default.yaml"); + await Bun.write(base, `invalid: [${sentinel}`); + const offline = await invoke(["config", "validate", "--json"]); + require(offline.exit === 0 && + offline.value.ok === true, "validation does not parse managed YAML"); + await refused([], "E_CONFIG_METADATA"); + await layer(primary, "hack.env.default.yaml", { + global: { TOKEN: secret, SOURCE: secret, EMPTY: "", ERASE: secret }, + init: { JOB: secret }, + host: { HOST_ONLY: secret }, + retired: { OLD: secret }, + }); + const first = await planned(); + const report = record(first.value.environment_plan); + const workloads = record(report.workloads); + const web = record(workloads.web); + require(report.complete === true && + report.overlay_exists === false && + JSON.stringify(report.warnings).includes("missing_overlay") && + JSON.stringify(report.warnings).includes( + "inactive_env_scope" + ), "missing overlay fallback and inactive scope warnings"); + require(record(web.TOKEN).secret === true && + record(web.COPY).key === "SOURCE" && + record(web.EMPTY).kind === "managed" && + record(web.FALLBACK).kind === "default" && + record(web.PUBLIC).value === "authored public text" && + !Object.hasOwn(web, "ERASE") && + !Object.hasOwn( + web, + "HOST_ONLY" + ), "immutable baseline, empty presence, literal/default/unset and host separation"); + require(Object.hasOwn(web, "__proto__") && + record(web.__proto__).value === + "authored own binding", "prototype-named authored destination survives report projection"); + require(record(record(workloads.host).HOST_ONLY).scope === "host" && + record(record(workloads.init).JOB).scope === "init" && + !Object.hasOwn(workloads, "optional") && + record(first.value.declared_workloads).optional === + "service", "jobs, inactive declarations and workload named host"); + require(first.value.semantic_hash === offline.value.semantic_hash && + record(first.value.local_resolution).resolution_hash === + record(offline.value.local_resolution) + .resolution_hash, "metadata leaves both identities unchanged"); + await layer(primary, "hack.env.default.yaml", { + global: { + TOKEN: `${sentinel}-changed`, + SOURCE: secret, + EMPTY: "", + ERASE: secret, + }, + init: { JOB: secret }, + host: { HOST_ONLY: secret }, + retired: { OLD: secret }, + }); + const changed = await planned(); + require(record(record(record(changed.value.environment_plan).workloads).web) + .TOKEN !== undefined && + changed.value.semantic_hash === first.value.semantic_hash && + record(changed.value.local_resolution).resolution_hash === + record(first.value.local_resolution) + .resolution_hash, "changed managed values cannot change identities"); + await layer(primary, "hack.env.qa.yaml", { global: { TOKEN: null } }, "qa"); + const tombstone = await planned([], primary, false); + require(JSON.stringify( + record(tombstone.value.environment_plan).diagnostics + ).includes( + "missing_env_reference" + ), "tombstone removes a required baseline key"); + await rm(join(primary, ".hack/hack.env.qa.yaml")); + const inactive = await planned(["--profile", "dev"], primary, false); + require(JSON.stringify( + record(inactive.value.environment_plan).diagnostics + ).includes( + "/services/optional/environment/REQUIRED" + ), "selected profile enforces required references"); + await layer(primary, "hack.env.local.yaml", { + web: { COPY: "existing destination" }, + }); + const collision = await planned([], primary, false); + require(JSON.stringify( + record(collision.value.environment_plan).diagnostics + ).includes( + "env_reference_collision" + ), "remap into an existing managed destination refuses"); + await rm(join(primary, ".hack/hack.env.local.yaml")); + await git(["init", "--quiet"], primary); + await git( + ["add", ".hack/hack.project.json", ".hack/hack.env.default.yaml"], + primary + ); + await git( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ], + primary + ); + const checkout = join(directory, "linked"); + await git( + ["worktree", "add", "--quiet", "-b", "fixture-linked", checkout], + primary + ); + await layer(primary, "hack.env.local.yaml", { + web: { TOKEN: secret, PRIMARY: secret }, + }); + await layer( + primary, + "hack.env.qa.local.yaml", + { web: { SOURCE: secret } }, + "qa" + ); + await layer(checkout, "hack.env.local.yaml", { web: { TOKEN: "" } }); + await layer( + checkout, + "hack.env.qa.local.yaml", + { web: { CURRENT: secret } }, + "qa" + ); + const inherited = await planned([], checkout); + const inheritedWeb = record( + record(record(inherited.value.environment_plan).workloads).web + ); + require(record(inheritedWeb.TOKEN).secret === false && + Object.hasOwn(inheritedWeb, "PRIMARY") && + Object.hasOwn(inheritedWeb, "CURRENT") && + record(inheritedWeb.COPY).scope === + "web", "six-layer linked-worktree precedence"); + const ci = await planned([], checkout, true, { CI: "1" }); + const slim = await planned([], checkout, true, { + HACK_EXECUTION_MODE: "codex", + }); + for (const excluded of [ci, slim]) { + require(!Object.hasOwn( + record(record(record(excluded.value.environment_plan).workloads).web), + "PRIMARY" + ), "CI/slim excludes primary metadata"); + } + await Bun.write( + join(checkout, ".hack/hack.project.json"), + JSON.stringify({ ...project, worktree: { inherit_local: false } }) + ); + const optedOut = await planned([], checkout); + require(!Object.hasOwn( + record(record(record(optedOut.value.environment_plan).workloads).web), + "PRIMARY" + ), "authored opt-out excludes primary metadata"); + await Bun.write( + join(checkout, ".hack/hack.project.json"), + JSON.stringify(project) + ); + const selectedBase = await planned(["--env", "base"], checkout); + require(record(selectedBase.value.environment_plan).overlay === null && + !Object.hasOwn( + record(record(record(selectedBase.value.environment_plan).workloads).web), + "CURRENT" + ), "explicit base excludes named local layer"); + await Bun.write( + join(checkout, ".hack/hack.local.json"), + '{"schema_version":1,"environment":{"default_overlay":"other"}}' + ); + const localSelected = await planned([], checkout); + require(record(localSelected.value.environment_plan).overlay === "other" && + JSON.stringify( + record(localSelected.value.environment_plan).warnings + ).includes( + "checkout_local" + ), "missing-overlay warning identifies local origin"); + await rm(join(checkout, ".hack/hack.local.json")); + await rm(join(checkout, ".hack/hack.env.qa.local.yaml")); + await symlink(base, join(checkout, ".hack/hack.env.qa.local.yaml")); + await refused([], "E_CONFIG_METADATA", checkout); + await rm(join(checkout, ".hack/hack.env.qa.local.yaml")); + await Bun.write(join(checkout, ".hack/hack.config.json"), "{}"); + await refused([], "E_NATIVE_PROJECT_CONFLICT", checkout); + await rm(join(checkout, ".hack/hack.config.json")); + const unsupported = await invoke(["up", "--json"], checkout); + require(unsupported.exit !== 0 && + (unsupported.stdout + unsupported.stderr).includes( + "E_NATIVE_PROJECT_UNSUPPORTED" + ), "native runtime remains fenced"); + require((await readdir(home)).length === 0 && + !(await readdir(join(checkout, ".hack"))).some( + (name) => name === ".internal" || name === ".branch" + ), "no registry or generated runtime state"); + process.stdout.write( + "Relocated native env plan acceptance: offline validation, metadata-only output, missing overlays/refs, immutable directives, separate hashes, real linked-worktree layers, CI/slim/opt-out, unsafe-input and runtime refusal passed\n" + ); +} finally { + await rm(directory, { recursive: true, force: true }); +} + +async function invoke( + args: readonly string[], + cwd = primary, + extraEnv: Readonly> = {} +) { + const child = Bun.spawn([join(bundle, "hack"), ...args], { + cwd, + env: { + PATH: "/usr/bin:/bin", + HOME: home, + HACK_LOGGER: "console", + AWS_SECRET_ACCESS_KEY: sentinel, + ...extraEnv, + }, + 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, + ]); + require(!( + (stdout + stderr).includes(sentinel) || + (stdout + stderr).includes(directory) + ), "redacted managed values and private paths"); + let value: Record = {}; + if (args[0] === "config") { + value = record(JSON.parse(stdout)); + } + return { stdout, stderr, exit, value }; + } finally { + clearTimeout(timer); + } +} + +async function planned( + args: readonly string[] = [], + cwd = primary, + complete = true, + extraEnv: Readonly> = {} +) { + const result = await invoke( + ["config", "plan", ...args, "--json"], + cwd, + extraEnv + ); + require(result.value.ok === true && + result.exit === (complete ? 0 : 1) && + record(result.value.environment_plan).complete === + complete, "binding completeness and exit status"); + return result; +} +async function refused(args: readonly string[], code: string, cwd = primary) { + const result = await invoke(["config", "plan", ...args, "--json"], cwd); + require(result.exit === 1 && + result.value.ok === false && + record(result.value.error).code === code, "fixed refusal code"); +} +async function layer( + cwd: string, + filename: string, + values: Record, + environment = "default" +) { + await Bun.write( + join(cwd, ".hack", filename), + JSON.stringify({ + version: 1, + environment, + secretsprovider: "project_key", + values, + }) + ); +} +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" }, + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exit] = await Promise.all([ + new Response(child.stderr).text(), + child.exited, + ]); + require(exit === 0, `isolated Git fixture: ${stderr}`); +} +function record(value: unknown): Record { + require(isRecord(value), "expected report object"); + return value; +} +function require(condition: boolean, label: string): asserts condition { + if (!condition) { + throw new Error(`Native env plan CLI acceptance failed: ${label}`); + } +} diff --git a/src/commands/config-plan.ts b/src/commands/config-plan.ts new file mode 100644 index 000000000..bd7d2493c --- /dev/null +++ b/src/commands/config-plan.ts @@ -0,0 +1,112 @@ +import { resolve } from "node:path"; +import { + CliUsageError, + defineCommand, + defineOption, + withHandler, +} from "../cli/command.ts"; +import { optEnv, optJson, optPath } from "../cli/options.ts"; +import { HackCliError } from "../lib/cli-result.ts"; +import { + NativeConfigCompilerError, + type NativeConfigDiagnostic, + type NativeConfigPlanResult, +} from "../lib/native-config-compiler.ts"; +import { planNativeProject } from "../lib/native-project-validation.ts"; + +const spec = defineCommand({ + name: "plan", + summary: + "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.", + options: [ + defineOption({ + name: "profile", + type: "string", + long: "--profile", + valueHint: "", + description: "Comma-separated declared native profiles", + } as const), + optPath, + optEnv, + optJson, + ], + positionals: [], + subcommands: [], +} as const); + +export const configPlanCommand = withHandler(spec, async ({ ctx, args }) => { + const profiles = args.options.profile?.split(",").map((name) => name.trim()); + if (profiles?.some((name) => name.length === 0)) { + throw new CliUsageError("Native profile names must not be empty."); + } + const controller = new AbortController(); + const cancel = () => controller.abort(); + process.once("SIGINT", cancel); + process.once("SIGTERM", cancel); + try { + const result = await planNativeProject({ + startDir: resolve(ctx.cwd, args.options.path ?? "."), + profiles, + explicitOverlay: args.options.env === "base" ? null : args.options.env, + signal: controller.signal, + }); + renderPlan({ result, json: args.options.json === true }); + return result.ok && result.environment_plan.complete ? 0 : 1; + } catch (error: unknown) { + const failure = + error instanceof NativeConfigCompilerError || + error instanceof HackCliError + ? error + : new NativeConfigCompilerError( + "E_COMPILER_REQUEST", + "Native configuration planning failed; values omitted." + ); + if (args.options.json) { + process.stdout.write( + `${JSON.stringify({ transport_version: 1, ok: false, error: { code: failure.code, message: failure.message } })}\n` + ); + } else { + process.stderr.write(`${failure.code}: ${failure.message}\n`); + } + return 1; + } finally { + process.removeListener("SIGINT", cancel); + process.removeListener("SIGTERM", cancel); + } +}); + +function renderPlan(opts: { + readonly result: NativeConfigPlanResult; + readonly json: boolean; +}): void { + const result = opts.result; + if (opts.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + if (!result.ok) { + for (const diagnostic of result.diagnostics) { + renderDiagnostic(diagnostic); + } + return; + } + const envPlan = result.environment_plan; + process.stdout.write( + `Native environment bindings are ${envPlan.complete ? "complete" : "incomplete"}. Semantic hash: ${result.semantic_hash}\n` + ); + process.stdout.write( + `Selected env: ${envPlan.overlay ?? "base"} (${result.local_resolution.origin}). This report does not establish runtime admission.\n` + ); + for (const diagnostic of [...envPlan.warnings, ...envPlan.diagnostics]) { + renderDiagnostic(diagnostic); + } +} + +function renderDiagnostic(diagnostic: NativeConfigDiagnostic): void { + process.stderr.write( + `${diagnostic.document ?? "project"} ${diagnostic.code} ${JSON.stringify(diagnostic.pointer || "/")} (${diagnostic.line}:${diagnostic.column}): ${diagnostic.message}\n` + ); +} diff --git a/src/commands/config.ts b/src/commands/config.ts index 709968170..29e265b7c 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -29,6 +29,7 @@ import { upsertProjectRegistration, } from "../lib/projects-registry.ts"; import { logger } from "../ui/logger.ts"; +import { configPlanCommand } from "./config-plan.ts"; import { configValidateCommand } from "./config-validate.ts"; type ConfigReadResult = @@ -210,6 +211,7 @@ export const configCommand = defineCommand({ withHandler(configGetSpec, handleConfigGet), withHandler(configSetSpec, handleConfigSet), configValidateCommand, + configPlanCommand, ], } as const); diff --git a/src/lib/native-config-compiler.ts b/src/lib/native-config-compiler.ts index bf85ed08e..24db97272 100644 --- a/src/lib/native-config-compiler.ts +++ b/src/lib/native-config-compiler.ts @@ -1,6 +1,14 @@ import { stat } from "node:fs/promises"; import { dirname, isAbsolute, join } from "node:path"; import { isRecord } from "./guards.ts"; +import { + type NativeDeclaredWorkloads, + type NativeEnvironmentPlan, + type NativeEnvMetadata, + parseDeclaredWorkloads, + parseNativeEnvironmentPlan, + parseNativeEnvMetadata, +} from "./native-env-plan-protocol.ts"; export const NATIVE_CONFIG_INPUT_LIMIT = 1024 * 1024; const OUTPUT_LIMIT = 8 * 1024 * 1024; @@ -39,12 +47,20 @@ export type NativeConfigResolveResult = }) | Extract; +export type NativeConfigPlanResult = + | (Extract & { + readonly declared_workloads: NativeDeclaredWorkloads; + readonly environment_plan: NativeEnvironmentPlan; + }) + | Extract; + export type NativeConfigCompileResult = | { readonly transport_version: 1; readonly ok: true; readonly plan: Readonly>; readonly semantic_hash: string; + readonly declared_workloads?: NativeDeclaredWorkloads; } | { readonly transport_version: 1; @@ -122,6 +138,7 @@ export async function compileNativeConfig(opts: { readonly timeoutMs?: number; readonly signal?: AbortSignal; readonly requireLocalResolution?: boolean; + readonly requireEnvPlanning?: boolean; }): Promise { if (opts.input.byteLength > NATIVE_CONFIG_INPUT_LIMIT) { throw failure( @@ -133,13 +150,21 @@ export async function compileNativeConfig(opts: { await checkProtocol({ ...request, requireLocalResolution: opts.requireLocalResolution, + requireEnvPlanning: opts.requireEnvPlanning, }); const response = await invokeCompiler({ ...request, args: compileArguments("compile", opts.profiles), input: opts.input, }); - return parseCompileResponse(response); + const result = parseCompileResponse(response); + if (opts.requireEnvPlanning && result.ok && !result.declared_workloads) { + throw failure( + "E_COMPILER_RESPONSE", + "Native compiler omitted the declared workload namespace." + ); + } + return result; } /** Resolve original document text in Rust; this transport never parses local policy. */ @@ -152,7 +177,47 @@ export async function resolveNativeConfig(opts: { readonly profiles?: readonly string[]; readonly timeoutMs?: number; readonly signal?: AbortSignal; + readonly requireEnvPlanning?: boolean; }): Promise { + const input = encodeResolveRequest(opts); + const request = compilerRequest(opts); + await checkProtocol({ + ...request, + requireLocalResolution: true, + requireEnvPlanning: opts.requireEnvPlanning, + }); + const response = await invokeCompiler({ + ...request, + args: compileArguments("resolve", opts.profiles), + input, + }); + const parsed = parseResolveResponse(response); + if (!parsed.ok) { + return parsed; + } + if (opts.requireEnvPlanning && !parsed.declared_workloads) { + throw failure( + "E_COMPILER_RESPONSE", + "Native compiler omitted the declared workload namespace." + ); + } + const { envelope, ...result } = parsed; + void envelope; + return result; +} + +type NativeResolveInputs = { + readonly input: Uint8Array; + readonly primaryLocal?: Uint8Array; + readonly checkoutLocal?: Uint8Array; + readonly explicitOverlay?: string | null; +}; + +function encodeResolveRequest( + opts: NativeResolveInputs & { + readonly envMetadata?: NativeEnvMetadata; + } +): Uint8Array { if ( typeof opts.explicitOverlay === "string" && Buffer.byteLength(opts.explicitOverlay, "utf8") > NATIVE_CONFIG_INPUT_LIMIT @@ -175,6 +240,7 @@ export async function resolveNativeConfig(opts: { ? undefined : documentText(opts.checkoutLocal, "checkout_local"), explicit_overlay: opts.explicitOverlay, + env_metadata: opts.envMetadata, }) ); if (input.byteLength > RESOLVE_REQUEST_LIMIT) { @@ -183,17 +249,122 @@ export async function resolveNativeConfig(opts: { "Native local resolution request exceeds the input budget." ); } + return input; +} + +/** Inspect only metadata supplied by the managed-env owner; no decryption or runtime effects. */ +export async function planNativeConfig( + opts: NativeResolveInputs & { + readonly envMetadata: NativeEnvMetadata; + readonly binary?: string; + readonly profiles?: readonly string[]; + readonly timeoutMs?: number; + readonly signal?: AbortSignal; + } +): Promise { + const metadata = parseNativeEnvMetadata(opts.envMetadata); + if ( + !metadata || + Buffer.byteLength(JSON.stringify(metadata), "utf8") > + NATIVE_CONFIG_INPUT_LIMIT + ) { + throw failure( + "E_CONFIG_METADATA", + "Native environment metadata is invalid or exceeds its budget; values omitted." + ); + } + const input = encodeResolveRequest({ ...opts, envMetadata: metadata }); const request = compilerRequest(opts); - await checkProtocol({ ...request, requireLocalResolution: true }); + await checkProtocol({ + ...request, + requireLocalResolution: true, + requireEnvPlanning: true, + }); const response = await invokeCompiler({ ...request, - args: compileArguments("resolve", opts.profiles), + args: compileArguments("plan", opts.profiles), input, }); - const envelope = parseControlJson(response.output); + const parsed = parseResolveResponse({ + ...response, + allowIncompletePlan: true, + }); + if (!parsed.ok) { + return parsed; + } + const environmentPlan = parseNativeEnvironmentPlan({ + value: parsed.envelope.environment_plan, + parseDiagnostic, + }); + if ( + !(parsed.declared_workloads && environmentPlan) || + environmentPlan.overlay !== parsed.local_resolution.overlay || + environmentPlan.overlay !== metadata.overlay || + environmentPlan.overlay_exists !== metadata.overlay_exists || + !environmentPlanMatchesSelection({ + plan: parsed.plan, + declared: parsed.declared_workloads, + environmentPlan, + }) || + response.exitCode !== (environmentPlan.complete ? 0 : 1) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native environment planning returned an invalid result." + ); + } + const { envelope, ...result } = parsed; + void envelope; + return { + ...result, + declared_workloads: parsed.declared_workloads, + environment_plan: environmentPlan, + }; +} + +/** Reject inconsistent compiler envelopes without interpreting authored selection policy. */ +function environmentPlanMatchesSelection(opts: { + readonly plan: Readonly>; + readonly declared: NativeDeclaredWorkloads; + readonly environmentPlan: NativeEnvironmentPlan; +}): boolean { + const selected = new Set(); + for (const [field, kind] of [ + ["services", "service"], + ["jobs", "job"], + ] as const) { + const workloads = opts.plan[field]; + if (!isRecord(workloads)) { + return false; + } + for (const name of Object.keys(workloads)) { + if (selected.has(name) || opts.declared[name] !== kind) { + return false; + } + selected.add(name); + } + } + const reportNames = Object.keys(opts.environmentPlan.workloads); + return ( + selected.size === reportNames.length && + reportNames.every((name) => selected.has(name)) + ); +} + +function parseResolveResponse(opts: { + readonly output: Uint8Array; + readonly exitCode: number; + readonly allowIncompletePlan?: boolean; +}): + | (Extract & { + readonly envelope: Readonly>; + }) + | Extract { + const envelope = parseControlJson(opts.output); const result = parseCompileValue({ value: envelope, - exitCode: response.exitCode, + exitCode: opts.exitCode, + allowIncompletePlan: opts.allowIncompletePlan, }); if (!result.ok) { if ( @@ -215,6 +386,7 @@ export async function resolveNativeConfig(opts: { return { ...result, local_resolution: parseLocalResolution(envelope.local_resolution), + envelope, }; } @@ -264,6 +436,7 @@ async function checkProtocol(opts: { readonly timeoutMs: number; readonly signal?: AbortSignal; readonly requireLocalResolution?: boolean; + readonly requireEnvPlanning?: boolean; }): Promise { const handshake = await invokeCompiler({ ...opts, args: ["--protocol"] }); const protocol = parseControlJson(handshake.output); @@ -273,6 +446,7 @@ async function checkProtocol(opts: { protocol.transport_version !== 1 || protocol.authored_version !== 1 || protocol.plan_version !== 1 || + (opts.requireEnvPlanning && protocol.env_plan_version !== 1) || (opts.requireLocalResolution && (protocol.resolve_version !== 1 || protocol.local_version !== 1)) ) { @@ -284,7 +458,7 @@ async function checkProtocol(opts: { } function compileArguments( - command: "compile" | "resolve", + command: "compile" | "resolve" | "plan", profiles?: readonly string[] ): string[] { const args: string[] = [command]; @@ -347,12 +521,14 @@ async function invokeCompiler(opts: { ); } let child: Bun.Subprocess<"ignore", "pipe", "pipe">; + const ownsProcessGroup = process.platform !== "win32"; try { child = Bun.spawn([opts.binary, ...opts.args], { env: { PATH: "/usr/bin:/bin" }, stdin: opts.input === undefined ? "ignore" : opts.input, stdout: "pipe", stderr: "pipe", + detached: ownsProcessGroup, }); } catch { throw failure( @@ -362,34 +538,35 @@ async function invokeCompiler(opts: { } let timedOut = false; let cancelled = false; + const io = new AbortController(); + const kill = () => { + killOwnedCompiler({ child, ownsProcessGroup }); + io.abort(); + }; const cancel = () => { cancelled = true; - child.kill("SIGKILL"); + kill(); }; const timer = setTimeout(() => { timedOut = true; - child.kill("SIGKILL"); + kill(); }, opts.timeoutMs); opts.signal?.addEventListener("abort", cancel, { once: true }); if (opts.signal?.aborted) { cancel(); } + const outputRead = readBounded(child.stdout, OUTPUT_LIMIT, io.signal); + const errorRead = readBounded(child.stderr, STDERR_LIMIT, io.signal); try { const [output, , exitCode] = await Promise.all([ - readBounded(child.stdout, OUTPUT_LIMIT), - readBounded(child.stderr, STDERR_LIMIT), + outputRead, + errorRead, child.exited, ]); - if (cancelled || timedOut) { - throw failure( - cancelled ? "E_COMPILER_CANCELLED" : "E_COMPILER_TIMEOUT", - cancelled - ? "Native configuration validation was cancelled." - : "Native configuration compiler timed out." - ); - } + throwIfCompilerInterrupted({ cancelled, timedOut }); return { output, exitCode }; } catch (error: unknown) { + throwIfCompilerInterrupted({ cancelled, timedOut }); if (error instanceof NativeConfigCompilerError) { throw error; } @@ -400,20 +577,63 @@ async function invokeCompiler(opts: { } finally { clearTimeout(timer); opts.signal?.removeEventListener("abort", cancel); - if (child.exitCode === null) { - child.kill("SIGKILL"); - await child.exited; + // A wrapper may leave descendants holding the pipes after the leader exits. + kill(); + await Promise.allSettled([outputRead, errorRead, child.exited]); + } +} + +function throwIfCompilerInterrupted(opts: { + readonly cancelled: boolean; + readonly timedOut: boolean; +}): void { + if (opts.cancelled || opts.timedOut) { + throw failure( + opts.cancelled ? "E_COMPILER_CANCELLED" : "E_COMPILER_TIMEOUT", + opts.cancelled + ? "Native configuration validation was cancelled." + : "Native configuration compiler timed out." + ); + } +} + +function killOwnedCompiler(opts: { + readonly child: Bun.Subprocess<"ignore", "pipe", "pipe">; + readonly ownsProcessGroup: boolean; +}): void { + if (opts.ownsProcessGroup) { + try { + process.kill(-opts.child.pid, "SIGKILL"); + } catch { + // An exited group is already clean; still reap a live direct child below. + } + } + if (opts.child.exitCode === null) { + try { + opts.child.kill("SIGKILL"); + } catch { + // Child exit may race the group signal. } } } async function readBounded( stream: ReadableStream, - limit: number + limit: number, + signal?: AbortSignal ): Promise { const reader = stream.getReader(); const chunks: Uint8Array[] = []; let size = 0; + const cancel = () => { + reader.cancel().catch(() => { + // A concurrently closed stream already satisfies cancellation. + }); + }; + signal?.addEventListener("abort", cancel, { once: true }); + if (signal?.aborted) { + cancel(); + } try { while (true) { const next = await reader.read(); @@ -422,7 +642,7 @@ async function readBounded( } size += next.value.byteLength; if (size > limit) { - await reader.cancel(); + cancel(); throw failure( "E_COMPILER_BUDGET", "Native configuration I/O exceeds its budget." @@ -431,6 +651,7 @@ async function readBounded( chunks.push(next.value); } } finally { + signal?.removeEventListener("abort", cancel); reader.releaseLock(); } const output = new Uint8Array(size); @@ -466,6 +687,7 @@ function parseCompileResponse(opts: { function parseCompileValue(opts: { readonly value: unknown; readonly exitCode: number; + readonly allowIncompletePlan?: boolean; }): NativeConfigCompileResult { const value = opts.value; if (!isRecord(value) || value.transport_version !== 1) { @@ -475,18 +697,30 @@ function parseCompileValue(opts: { ); } if ( - opts.exitCode === 0 && + (opts.exitCode === 0 || + (opts.allowIncompletePlan && opts.exitCode === 1)) && value.ok === true && isRecord(value.plan) && value.plan.plan_version === 1 && typeof value.semantic_hash === "string" && HASH_PATTERN.test(value.semantic_hash) ) { + const declared = + value.declared_workloads === undefined + ? undefined + : parseDeclaredWorkloads(value.declared_workloads); + if (declared === null) { + throw failure( + "E_COMPILER_RESPONSE", + "Native compiler returned an invalid workload namespace." + ); + } return { transport_version: 1, ok: true, plan: value.plan, semantic_hash: value.semantic_hash, + ...(declared === undefined ? {} : { declared_workloads: declared }), }; } if ( diff --git a/src/lib/native-env-plan-protocol.ts b/src/lib/native-env-plan-protocol.ts new file mode 100644 index 000000000..478f97995 --- /dev/null +++ b/src/lib/native-env-plan-protocol.ts @@ -0,0 +1,225 @@ +import type { + EnvironmentBinding, + EnvironmentPlan, + EnvMetadata, + ManagedBindingMetadata, + WorkloadKind, +} from "../../packages/config-compiler/generated/native-config.ts"; +import { isRecord } from "./guards.ts"; +import type { NativeConfigDiagnostic } from "./native-config-compiler.ts"; + +const WORKLOAD_NAME = /^[a-z0-9][a-z0-9._-]{0,62}$/; +const MANAGED_KEY = /^[A-Z_][A-Z0-9_]*$/; +const AUTHORED_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SCOPE = /^[a-z0-9][a-z0-9._-]*$/; +const OVERLAY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; + +export type NativeDeclaredWorkloads = Readonly>; +export type NativeManagedEntry = Readonly; +export type NativeEnvMetadata = Readonly< + Omit +> & { + readonly workloads: Readonly< + Record>> + >; + readonly inactive_scopes: readonly string[]; +}; +export type NativeEnvBinding = Readonly; +export type NativeEnvironmentPlan = Readonly< + Omit +> & { + readonly workloads: Readonly< + Record>> + >; + readonly warnings: readonly NativeConfigDiagnostic[]; + readonly diagnostics: readonly NativeConfigDiagnostic[]; +}; + +function hasOnly( + value: Record, + keys: readonly string[] +): boolean { + return Object.keys(value).every((key) => keys.includes(key)); +} + +function isOverlay(value: unknown): value is string | null { + return value === null || (typeof value === "string" && OVERLAY.test(value)); +} + +/** Wire projection only; authored policy and binding decisions remain in Rust. */ +export function parseDeclaredWorkloads( + value: unknown +): NativeDeclaredWorkloads | null { + if (!isRecord(value)) { + return null; + } + const output: Record = {}; + for (const [name, kind] of Object.entries(value)) { + if (!WORKLOAD_NAME.test(name) || (kind !== "service" && kind !== "job")) { + return null; + } + output[name] = kind; + } + return output; +} + +/** Never forward extra stored values or paths through the metadata transport. */ +export function parseNativeEnvMetadata( + value: unknown +): NativeEnvMetadata | null { + if ( + !( + isRecord(value) && + hasOnly(value, [ + "metadata_version", + "overlay", + "overlay_exists", + "workloads", + "inactive_scopes", + ]) + ) || + value.metadata_version !== 1 || + !isOverlay(value.overlay) || + typeof value.overlay_exists !== "boolean" || + !isRecord(value.workloads) || + !Array.isArray(value.inactive_scopes) + ) { + return null; + } + const workloads: Record> = {}; + for (const [name, entries] of Object.entries(value.workloads)) { + if (!(WORKLOAD_NAME.test(name) && isRecord(entries))) { + return null; + } + const projected: Record = {}; + for (const [key, entry] of Object.entries(entries)) { + if ( + !( + MANAGED_KEY.test(key) && + isRecord(entry) && + hasOnly(entry, ["scope", "secret"]) + ) || + typeof entry.scope !== "string" || + !SCOPE.test(entry.scope) || + typeof entry.secret !== "boolean" + ) { + return null; + } + projected[key] = { scope: entry.scope, secret: entry.secret }; + } + workloads[name] = projected; + } + const inactiveScopes: string[] = []; + for (const scope of value.inactive_scopes) { + if (typeof scope !== "string" || !SCOPE.test(scope)) { + return null; + } + inactiveScopes.push(scope); + } + return { + metadata_version: 1, + overlay: value.overlay, + overlay_exists: value.overlay_exists, + workloads, + inactive_scopes: inactiveScopes, + }; +} + +function parseBinding(value: unknown): NativeEnvBinding | null { + if (!isRecord(value)) { + return null; + } + if ( + value.kind === "managed" && + hasOnly(value, ["kind", "key", "scope", "secret"]) && + typeof value.key === "string" && + MANAGED_KEY.test(value.key) && + typeof value.scope === "string" && + SCOPE.test(value.scope) && + typeof value.secret === "boolean" + ) { + return { + kind: "managed", + key: value.key, + scope: value.scope, + secret: value.secret, + }; + } + if ( + (value.kind === "literal" || value.kind === "default") && + hasOnly(value, ["kind", "value"]) && + typeof value.value === "string" + ) { + return { kind: value.kind, value: value.value }; + } + return null; +} + +/** Validate the names-only report envelope without deciding effective bindings. */ +export function parseNativeEnvironmentPlan(opts: { + readonly value: unknown; + readonly parseDiagnostic: (value: unknown) => NativeConfigDiagnostic; +}): NativeEnvironmentPlan | null { + const value = opts.value; + if ( + !( + isRecord(value) && + hasOnly(value, [ + "plan_version", + "overlay", + "overlay_exists", + "complete", + "workloads", + "warnings", + "diagnostics", + ]) + ) || + value.plan_version !== 1 || + !isOverlay(value.overlay) || + typeof value.overlay_exists !== "boolean" || + typeof value.complete !== "boolean" || + !isRecord(value.workloads) || + !Array.isArray(value.warnings) || + !Array.isArray(value.diagnostics) + ) { + return null; + } + const workloads: Record> = {}; + for (const [name, entries] of Object.entries(value.workloads)) { + if (!(WORKLOAD_NAME.test(name) && isRecord(entries))) { + return null; + } + const bindings: Record = {}; + for (const [key, entry] of Object.entries(entries)) { + const binding = parseBinding(entry); + if (!(AUTHORED_KEY.test(key) && binding)) { + return null; + } + // Authored destinations may include __proto__; define an own data key. + Object.defineProperty(bindings, key, { + value: binding, + enumerable: true, + configurable: true, + writable: true, + }); + } + workloads[name] = bindings; + } + const warnings = value.warnings.map(opts.parseDiagnostic); + const diagnostics = value.diagnostics.map(opts.parseDiagnostic); + if ( + value.complete !== (diagnostics.length === 0) || + [...warnings, ...diagnostics].some((entry) => entry.document === undefined) + ) { + return null; + } + return { + plan_version: 1, + overlay: value.overlay, + overlay_exists: value.overlay_exists, + complete: value.complete, + workloads, + warnings, + diagnostics, + }; +} diff --git a/src/lib/native-project-inputs.ts b/src/lib/native-project-inputs.ts index 1a5166891..5d4f5451c 100644 --- a/src/lib/native-project-inputs.ts +++ b/src/lib/native-project-inputs.ts @@ -20,6 +20,8 @@ import { } from "./worktree-local-config.ts"; const NATIVE_LOCAL_FILENAME = "hack.local.json"; +const MANAGED_ENV_FILENAME = + /^hack\.env\.[a-z0-9]+(?:-[a-z0-9]+)*(?:\.local)?\.yaml$/; /** Fixed failures never disclose authored bytes, paths, or filesystem error text. */ export class NativeProjectInputError extends HackCliError { @@ -132,7 +134,7 @@ export async function acquireNativeLocalInputs(opts: { } } -async function requireNativeFamilyAtRoot(opts: { +export async function requireNativeFamilyAtRoot(opts: { readonly projectRoot: string; }): Promise { const selected = await inspectProjectInputsAtRoot(opts); @@ -144,6 +146,58 @@ async function requireNativeFamilyAtRoot(opts: { } } +/** Verify an exact native root and its required marker without interpreting authored policy. */ +export async function assertNativeProjectInputRoot(opts: { + readonly projectRoot: string; + readonly signal?: AbortSignal; +}): Promise { + try { + throwIfCancelled(opts.signal); + const projectRoot = resolve(opts.projectRoot); + await requireNativeFamilyAtRoot({ projectRoot }); + await readAuthoredNativeFile({ + ...opts, + projectRoot, + filename: NATIVE_PROJECT_FILENAME, + required: true, + }); + await requireNativeFamilyAtRoot({ projectRoot }); + throwIfCancelled(opts.signal); + } catch (error: unknown) { + throw redactAcquisitionError(error); + } +} + +/** + * The managed-env owner may acquire only its YAML layers through the same bounded + * descriptor boundary. These bytes can contain secrets: never expose them outside + * that owner. File checks do not provide an atomic multi-file snapshot. + */ +export async function acquireNativeManagedEnvFile(opts: { + readonly projectRoot: string; + readonly filename: string; + readonly signal?: AbortSignal; +}): Promise { + try { + throwIfCancelled(opts.signal); + if (!MANAGED_ENV_FILENAME.test(opts.filename)) { + throw new NativeProjectInputError("unsafe"); + } + const projectRoot = resolve(opts.projectRoot); + await requireNativeFamilyAtRoot({ projectRoot }); + const bytes = await readAuthoredNativeFile({ + ...opts, + projectRoot, + required: false, + }); + await requireNativeFamilyAtRoot({ projectRoot }); + throwIfCancelled(opts.signal); + return bytes; + } catch (error: unknown) { + throw redactAcquisitionError(error); + } +} + type DirectoryIdentity = { readonly path: string; readonly stats: Stats; diff --git a/src/lib/native-project-validation.ts b/src/lib/native-project-validation.ts index e25e4cbc1..14566e271 100644 --- a/src/lib/native-project-validation.ts +++ b/src/lib/native-project-validation.ts @@ -2,21 +2,106 @@ import { isRecord } from "./guards.ts"; import { compileNativeConfig, NativeConfigCompilerError, + type NativeConfigPlanResult, type NativeConfigResolveResult, + planNativeConfig, resolveNativeConfig, } from "./native-config-compiler.ts"; import { acquireNativeLocalInputs, acquireNativeProjectInput, } from "./native-project-inputs.ts"; +import { resolveProjectEnvMetadataForNativeSelection } from "./project-env-config.ts"; -/** Offline project selection; local policy is read only after Rust validates it. */ -export async function validateNativeProject(opts: { +type NativeProjectSelection = { readonly startDir: string; readonly profiles?: readonly string[]; readonly explicitOverlay?: string | null; readonly signal?: AbortSignal; -}): Promise { +}; + +/** Offline project selection; local policy is read only after Rust validates it. */ +export async function validateNativeProject( + opts: NativeProjectSelection +): Promise { + return (await prepareNativeProject(opts)).result; +} + +/** Explicit metadata inspection; this is binding completeness, not runtime admission. */ +export async function planNativeProject( + opts: NativeProjectSelection +): Promise { + const prepared = await prepareNativeProject({ + ...opts, + requireEnvPlanning: true, + }); + const resolved = prepared.result; + if (!resolved.ok) { + return resolved; + } + const declared = resolved.declared_workloads; + if (!declared) { + throw new NativeConfigCompilerError( + "E_COMPILER_RESPONSE", + "Native compiler omitted the declared workload namespace." + ); + } + let metadata: Awaited< + ReturnType + >; + try { + metadata = await resolveProjectEnvMetadataForNativeSelection({ + projectRoot: prepared.projectRoot, + overlay: resolved.local_resolution.overlay, + inheritLocal: resolved.local_resolution.inherit_local, + declaredWorkloadNames: Object.keys(declared), + signal: opts.signal, + }); + } catch (error: unknown) { + if (error instanceof NativeConfigCompilerError) { + throw error; + } + throw new NativeConfigCompilerError( + "E_CONFIG_METADATA", + "Cannot inspect selected managed environment metadata; values omitted." + ); + } + const result = await planNativeConfig({ + ...opts, + input: prepared.input, + ...prepared.locals, + envMetadata: { + metadata_version: 1, + overlay: metadata.overlay, + overlay_exists: metadata.overlayExists, + workloads: Object.fromEntries( + Object.keys(declared).map((name) => [ + name, + metadata.effectiveMetadata[name] ?? {}, + ]) + ), + inactive_scopes: metadata.unknownScopes, + }, + }); + if ( + result.ok && + (result.semantic_hash !== resolved.semantic_hash || + result.local_resolution.resolution_hash !== + resolved.local_resolution.resolution_hash) + ) { + throw new NativeConfigCompilerError( + "E_COMPILER_RESPONSE", + "Native metadata planning changed the authored or local resolution identity." + ); + } + return result; +} + +async function prepareNativeProject( + opts: NativeProjectSelection & { + readonly requireEnvPlanning?: boolean; + } +) { const project = await acquireNativeProjectInput({ startDir: opts.startDir, signal: opts.signal, @@ -26,15 +111,17 @@ export async function validateNativeProject(opts: { profiles: opts.profiles, signal: opts.signal, requireLocalResolution: true, + requireEnvPlanning: opts.requireEnvPlanning, }); if (!compiled.ok) { - return { + const result: NativeConfigResolveResult = { ...compiled, diagnostics: compiled.diagnostics.map((diagnostic) => ({ ...diagnostic, document: "project" as const, })), }; + return { ...project, result, locals: {} }; } const worktree = compiled.plan.worktree; if (!isRecord(worktree) || typeof worktree.inherit_local !== "boolean") { @@ -48,11 +135,13 @@ export async function validateNativeProject(opts: { inheritLocal: worktree.inherit_local, signal: opts.signal, }); - return await resolveNativeConfig({ + const result = await resolveNativeConfig({ input: project.input, ...locals, profiles: opts.profiles, explicitOverlay: opts.explicitOverlay, signal: opts.signal, + requireEnvPlanning: opts.requireEnvPlanning, }); + return { ...project, result, locals }; } diff --git a/src/lib/project-env-config.ts b/src/lib/project-env-config.ts index a67802757..7b2a5146c 100644 --- a/src/lib/project-env-config.ts +++ b/src/lib/project-env-config.ts @@ -8,6 +8,7 @@ import { chmod, lstat, readdir, rm, stat } from "node:fs/promises"; import { basename, dirname, relative, resolve } from "node:path"; import { YAML } from "bun"; import { + HACK_PROJECT_DIR_PRIMARY, PROJECT_COMPOSE_FILENAME, PROJECT_CONFIG_FILENAME, PROJECT_ENV_CONFIG_DEFAULT_FILENAME, @@ -41,6 +42,14 @@ import { } from "./git-worktree.ts"; import { getRecord, getString, isRecord } from "./guards.ts"; import { readHackEnvContract, resolveHackEnv } from "./hack-env.ts"; +import { + NATIVE_CONFIG_INPUT_LIMIT, + NativeConfigCompilerError, +} from "./native-config-compiler.ts"; +import { + acquireNativeManagedEnvFile, + assertNativeProjectInputRoot, +} from "./native-project-inputs.ts"; import { readProjectDefaultEnvConfig } from "./project.ts"; import { assertLegacyProjectDirectory, @@ -48,12 +57,16 @@ import { ProjectInputSelectionError, } from "./project-input-selection.ts"; import { + isNativeManagedLocalTracked, resolvePrimaryLocalProjectDir, + resolveVerifiedPrimaryWorktreeRoot, + shouldInheritPrimaryLocalInputs, validatePrimaryLocalFile, } from "./worktree-local-config.ts"; const PROJECT_ENV_CONFIG_VERSION = 1 as const; const PROJECT_ENV_SECRETS_PROVIDER = "project_key" as const; +const NATIVE_ENV_OVERLAY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const PROJECT_ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; const PROJECT_ENV_SCOPE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; const PROJECT_ENV_SECRET_PREFIX = "v1" as const; @@ -963,12 +976,232 @@ export async function resolveProjectEnvMetadata( } } -/** One scope projection owns both metadata planning and runtime injection. */ +/** Native planning carries names and winning scope/secret flags, never stored values. */ +export type NativeProjectEnvMetadata = { + readonly overlay: string | null; + readonly overlayExists: boolean; + readonly effectiveMetadata: EffectiveEnvMetadata; + readonly unknownScopes: readonly string[]; +}; + +type NativeEnvSelectionOptions = { + readonly projectRoot: string; + readonly overlay: string | null; + readonly inheritLocal: boolean; + readonly declaredWorkloadNames: readonly string[]; + readonly signal?: AbortSignal; +}; + +function nativeEnvMetadataError(): Error { + return new Error( + "Cannot resolve native managed env metadata: selected inputs are invalid, unreadable, unstable or oversized; values omitted." + ); +} + +function checkNativeEnvCancellation(signal?: AbortSignal): void { + if (signal?.aborted) { + throw new NativeConfigCompilerError( + "E_COMPILER_CANCELLED", + "Native managed env metadata acquisition was cancelled." + ); + } +} + +async function readNativeEnvLayer(opts: { + readonly projectRoot: string; + readonly filename: string; + readonly environment: string; + readonly signal?: AbortSignal; +}): Promise { + const bytes = await acquireNativeManagedEnvFile(opts); + checkNativeEnvCancellation(opts.signal); + if (bytes === undefined) { + return null; + } + return parseNativeEnvLayer({ bytes, environment: opts.environment }); +} + +function parseNativeEnvLayer(opts: { + readonly bytes: Uint8Array; + readonly environment: string; +}): ProjectEnvConfig { + const parsed: unknown = YAML.parse( + new TextDecoder("utf-8", { fatal: true }).decode(opts.bytes) + ); + const result = parseProjectEnvConfig({ + parsed, + environment: opts.environment, + }); + if (!result.ok) { + throw nativeEnvMetadataError(); + } + return result.config; +} + +/** Preserve tracked local-overlay compatibility without reading legacy policy. */ +async function readNativeLocalBase(opts: { + readonly projectRoot: string; + readonly signal?: AbortSignal; +}): Promise { + const bytes = await acquireNativeManagedEnvFile({ + ...opts, + filename: "hack.env.local.yaml", + }); + if (bytes === undefined) { + return null; + } + const tracked = await isNativeManagedLocalTracked(opts); + const local = + tracked === true + ? null + : parseNativeEnvLayer({ bytes, environment: "default" }); + if (tracked ?? local?.environment === "local") { + return await readNativeEnvLayer({ + ...opts, + filename: "hack.env.default.local.yaml", + environment: "default", + }); + } + return local; +} + +/** + * Resolve an explicit native selection through the existing managed-env owner. + * Does not read legacy policy/defaults, dotenv, keys, or decrypt stored entries. + * Only missing files are optional; selected files are bounded stable regular + * files in unredirected native roots. This is not an atomic multi-file snapshot. + * Unknown scopes remain names for diagnostics, not authorized workload targets. + */ +export async function resolveProjectEnvMetadataForNativeSelection( + opts: NativeEnvSelectionOptions +): Promise { + try { + checkNativeEnvCancellation(opts.signal); + if ( + opts.overlay !== null && + (typeof opts.overlay !== "string" || + !NATIVE_ENV_OVERLAY_PATTERN.test(opts.overlay)) + ) { + throw nativeEnvMetadataError(); + } + const projectRoot = resolve(opts.projectRoot); + await assertNativeProjectInputRoot({ projectRoot, signal: opts.signal }); + let primaryRoot: string | null = null; + if (shouldInheritPrimaryLocalInputs(opts)) { + primaryRoot = await resolveVerifiedPrimaryWorktreeRoot({ + projectRoot, + signal: opts.signal, + }); + if (primaryRoot) { + await assertNativeProjectInputRoot({ + projectRoot: primaryRoot, + signal: opts.signal, + }); + } + } + const read = (root: string, envName: string | null, local: boolean) => { + const projectDir = resolve(root, HACK_PROJECT_DIR_PRIMARY); + return readNativeEnvLayer({ + projectRoot: root, + filename: basename( + local + ? resolveProjectEnvLocalConfigPath({ projectDir, envName }) + : resolveProjectEnvConfigPath({ projectDir, envName }) + ), + environment: envName ?? "default", + signal: opts.signal, + }); + }; + const base = await read(projectRoot, null, false); + const overlay = + opts.overlay === null + ? null + : await read(projectRoot, opts.overlay, false); + const envLayers = [ + base, + overlay, + primaryRoot + ? await readNativeLocalBase({ + projectRoot: primaryRoot, + signal: opts.signal, + }) + : null, + primaryRoot && opts.overlay !== null + ? await read(primaryRoot, opts.overlay, true) + : null, + await readNativeLocalBase({ projectRoot, signal: opts.signal }), + opts.overlay === null + ? null + : await read(projectRoot, opts.overlay, true), + ]; + const merged = mergeProjectEnvConfigLayers({ + layers: envLayers, + environment: opts.overlay ?? "default", + }); + const projection = projectEnvScopeProjection({ + layers: { envLayers, merged }, + serviceNames: opts.declaredWorkloadNames, + metadataByteLimit: NATIVE_CONFIG_INPUT_LIMIT, + includeHostMetadata: false, + }); + const result: NativeProjectEnvMetadata = { + overlay: opts.overlay, + overlayExists: overlay !== null, + effectiveMetadata: envLayers.some((layer) => layer !== null) + ? projection.metadata.effectiveMetadata + : {}, + unknownScopes: projection.metadata.unknownScopes, + }; + if (Buffer.byteLength(JSON.stringify(result)) > NATIVE_CONFIG_INPUT_LIMIT) { + throw nativeEnvMetadataError(); + } + await assertNativeProjectInputRoot({ projectRoot, signal: opts.signal }); + if (primaryRoot) { + await assertNativeProjectInputRoot({ + projectRoot: primaryRoot, + signal: opts.signal, + }); + } + checkNativeEnvCancellation(opts.signal); + return result; + } catch (error: unknown) { + if ( + error instanceof NativeConfigCompilerError && + error.code === "E_COMPILER_CANCELLED" + ) { + throw error; + } + throw nativeEnvMetadataError(); + } +} + +/** Keep legacy selection provenance outside the shared scope projection. */ function projectEnvProjection(opts: { readonly layers: ProjectEnvLayers; readonly serviceNames: readonly string[]; }) { - const { selection, envLayers, merged, files } = opts.layers; + const projection = projectEnvScopeProjection(opts); + return { + ...projection, + metadata: { + ...projection.metadata, + selection: opts.layers.selection, + files: opts.layers.files, + }, + }; +} + +/** One scope projection owns metadata planning and runtime injection. */ +function projectEnvScopeProjection(opts: { + readonly layers: { + readonly envLayers: readonly (ProjectEnvConfig | null)[]; + readonly merged: ProjectEnvConfig; + }; + readonly serviceNames: readonly string[]; + readonly metadataByteLimit?: number; + readonly includeHostMetadata?: boolean; +}) { + const { envLayers, merged } = opts.layers; const declaredScopes = Object.keys(merged.values).sort((left, right) => left.localeCompare(right) ); @@ -998,6 +1231,19 @@ function projectEnvProjection(opts: { const effectiveMetadata: EffectiveEnvMetadata = { global: resolveMetadata({ layers: envLayers, scopeNames: ["global"] }), }; + let metadataBytes = + opts.metadataByteLimit === undefined + ? 0 + : Buffer.byteLength(JSON.stringify(effectiveMetadata)); + const checkMetadataSize = () => { + if ( + opts.metadataByteLimit !== undefined && + metadataBytes > opts.metadataByteLimit + ) { + throw nativeEnvMetadataError(); + } + }; + checkMetadataSize(); const hostEffectiveMetadata: EffectiveEnvMetadata = {}; for (const { serviceName, @@ -1008,21 +1254,29 @@ function projectEnvProjection(opts: { layers: envLayers, scopeNames: composeScopeNames, }); - hostEffectiveMetadata[serviceName] = resolveMetadata({ - layers: envLayers, - scopeNames: hostScopeNames, - }); + if (opts.metadataByteLimit !== undefined) { + metadataBytes += Buffer.byteLength( + JSON.stringify({ [serviceName]: effectiveMetadata[serviceName] }) + ); + checkMetadataSize(); + } + if (opts.includeHostMetadata !== false) { + hostEffectiveMetadata[serviceName] = resolveMetadata({ + layers: envLayers, + scopeNames: hostScopeNames, + }); + } } const globalHostScopeNames = hostScopeConflictsWithService ? ["global"] : ["global", PROJECT_ENV_HOST_SCOPE]; - hostEffectiveMetadata.global = resolveMetadata({ - layers: envLayers, - scopeNames: globalHostScopeNames, - }); - const metadata: ProjectEnvResolvedMetadata = { - selection, - files, + if (opts.includeHostMetadata !== false) { + hostEffectiveMetadata.global = resolveMetadata({ + layers: envLayers, + scopeNames: globalHostScopeNames, + }); + } + const metadata = { effectiveMetadata, hostEffectiveMetadata, declaredScopes, diff --git a/src/lib/worktree-local-config.ts b/src/lib/worktree-local-config.ts index ee66216ad..7b9534e05 100644 --- a/src/lib/worktree-local-config.ts +++ b/src/lib/worktree-local-config.ts @@ -89,6 +89,50 @@ type GitCheckoutIdentity = { readonly commonDir: string; }; +/** Inspect only the managed owner's historical local-overlay filename at an exact Git root. */ +export async function isNativeManagedLocalTracked(opts: { + readonly projectRoot: string; + readonly signal?: AbortSignal; +}): Promise { + try { + const projectRoot = resolve(opts.projectRoot); + const before = await readGitCheckoutIdentity({ ...opts, projectRoot }); + if (!before) { + return null; + } + const path = ".hack/hack.env.local.yaml"; + const output = await readGitInspection({ + ...opts, + projectRoot, + args: [ + "-c", + "core.fsmonitor=false", + "ls-files", + "--cached", + "-z", + "--", + path, + ], + }); + const after = await readGitCheckoutIdentity({ ...opts, projectRoot }); + if ( + output === null || + !after || + after.gitDir !== before.gitDir || + after.commonDir !== before.commonDir || + (output !== "" && output !== `${path}\0`) + ) { + throw worktreeVerificationError(); + } + return output !== ""; + } catch (error: unknown) { + if (error instanceof NativeConfigCompilerError) { + throw error; + } + throw worktreeVerificationError(); + } +} + async function readGitCheckoutIdentity(opts: { readonly projectRoot: string; readonly signal?: AbortSignal; diff --git a/tests/native-config-command.test.ts b/tests/native-config-command.test.ts index 7e6d74961..0a8af0aea 100644 --- a/tests/native-config-command.test.ts +++ b/tests/native-config-command.test.ts @@ -14,6 +14,7 @@ afterEach(async () => { async function startCli(opts: { compilerBody: string; + operation?: "validate" | "plan"; json?: boolean; project?: boolean; args?: readonly string[]; @@ -30,7 +31,7 @@ async function startCli(opts: { } await Bun.write( compiler, - `#!${process.execPath}\nif (process.argv[2] === '--protocol') { console.log('{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1}'); } else { ${opts.compilerBody} }` + `#!${process.execPath}\nif (process.argv[2] === '--protocol') { console.log('{"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1,"env_plan_version":1}'); } else { ${opts.compilerBody} }` ); await chmod(compiler, 0o755); return Bun.spawn( @@ -38,8 +39,8 @@ async function startCli(opts: { process.execPath, join(root, "index.ts"), "config", - "validate", - ...(opts.project ? [] : ["--file", file]), + opts.operation ?? "validate", + ...(opts.project || opts.operation === "plan" ? [] : ["--file", file]), ...(opts.args ?? []), ...(opts.json ? ["--json"] : []), ], @@ -148,3 +149,99 @@ test.each([ } } }); + +const PLAN_SUCCESS = { + transport_version: 1, + ok: true, + plan: { + plan_version: 1, + worktree: { auto_branch: true, inherit_local: true }, + services: {}, + jobs: {}, + }, + semantic_hash: "a".repeat(64), + declared_workloads: {}, + local_resolution: { + overlay: null, + origin: "project", + auto_branch: true, + inherit_local: true, + resolution_hash: "b".repeat(64), + }, +}; + +test("plan human report identifies incomplete bindings and escapes diagnostic pointers", async () => { + const response = { + ...PLAN_SUCCESS, + environment_plan: { + plan_version: 1, + overlay: null, + overlay_exists: false, + complete: false, + workloads: {}, + warnings: [], + diagnostics: [ + { + document: "project", + code: "missing_env_reference", + pointer: "/services/\u001b[2J\nforged", + message: "Required managed env reference is absent.", + line: 2, + column: 3, + }, + ], + }, + }; + const child = await startCli({ + operation: "plan", + project: true, + compilerBody: `const planning=process.argv[2]==='plan'; console.log(JSON.stringify(planning ? ${JSON.stringify(response)} : ${JSON.stringify(PLAN_SUCCESS)})); process.exitCode=planning ? 1 : 0;`, + }); + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + expect(exit).toBe(1); + expect(stdout).toContain("bindings are incomplete"); + expect(stdout).toContain("does not establish runtime admission"); + expect(stderr).toContain( + 'project missing_env_reference "/services/\\u001b[2J\\nforged" (2:3)' + ); + expect(stderr).not.toContain("\u001b[2J"); + expect(stderr).not.toContain("\nforged"); +}); + +test.each([ + "SIGINT", + "SIGTERM", +] as const)("plan CLI %s reaps its owned planning child", async (signal) => { + const pidPath = join(directory, "plan-pid"); + const child = await startCli({ + operation: "plan", + project: true, + json: true, + compilerBody: `if(process.argv[2]==='plan') { await Bun.write(${JSON.stringify(pidPath)},String(process.pid)); await Bun.sleep(10000); } else { console.log(${JSON.stringify(JSON.stringify(PLAN_SUCCESS))}); }`, + }); + try { + const deadline = Date.now() + 4000; + while (!(await Bun.file(pidPath).exists()) && Date.now() < deadline) { + await Bun.sleep(20); + } + expect(await Bun.file(pidPath).exists()).toBe(true); + const compilerPid = Number(await Bun.file(pidPath).text()); + child.kill(signal); + const [stdout, exit] = await Promise.all([ + new Response(child.stdout).text(), + child.exited, + ]); + expect(exit).toBe(1); + expect(JSON.parse(stdout).error.code).toBe("E_COMPILER_CANCELLED"); + expect(() => process.kill(compilerPid, 0)).toThrow(); + } finally { + if (child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + } +}); diff --git a/tests/native-env-metadata.test.ts b/tests/native-env-metadata.test.ts new file mode 100644 index 000000000..e9bae05d5 --- /dev/null +++ b/tests/native-env-metadata.test.ts @@ -0,0 +1,512 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { acquireNativeManagedEnvFile } from "../src/lib/native-project-inputs.ts"; +import { + type ProjectEnvValuesByScope, + resolveProjectEnvMetadataForNativeSelection, +} from "../src/lib/project-env-config.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const SENTINEL = "private-synthetic-value-must-not-escape"; +const secure = { secure: `invalid-ciphertext-${SENTINEL}` }; +const ENV_KEYS = [ + "PATH", + "CI", + "HACK_EXECUTION_MODE", + "HACK_ENV_SECRET_KEY", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", +] as const; +let root: string; +let saved: Record; +beforeEach(async () => { + saved = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of ENV_KEYS) { + if (key !== "PATH") { + Reflect.deleteProperty(process.env, key); + } + } + root = await realpath(await mkdtemp(join(tmpdir(), "native-env-metadata-"))); +}); +afterEach(async () => { + for (const key of ENV_KEYS) { + restoreEnv(key, saved[key]); + } + await rm(root, { recursive: true, force: true }); +}); +async function project(name = "primary") { + const projectRoot = join(root, name); + await mkdir(join(projectRoot, ".hack"), { recursive: true }); + // Selection is supplied by the compiler; this owner never parses project policy. + await writeFile( + join(projectRoot, ".hack/hack.project.json"), + `${SENTINEL} invalid json` + ); + return projectRoot; +} +async function layer( + projectRoot: string, + filename: string, + values: ProjectEnvValuesByScope, + environment = "default" +) { + await writeFile( + join(projectRoot, ".hack", filename), + JSON.stringify({ + version: 1, + environment, + secretsprovider: "project_key", + values, + }) + ); +} +function metadata( + projectRoot: string, + overlay: string | null = null, + inheritLocal = true, + declaredWorkloadNames: readonly string[] = ["web", "job", "inactive"] +) { + return resolveProjectEnvMetadataForNativeSelection({ + projectRoot, + overlay, + inheritLocal, + declaredWorkloadNames, + }); +} +async function git(projectRoot: string, args: string[]) { + const child = Bun.spawn(["git", "-C", projectRoot, ...args], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + expect(await child.exited).toBe(0); +} +async function linked() { + const primary = await project(); + await git(primary, ["init", "--quiet", "-b", "main"]); + await git(primary, ["add", ".hack/hack.project.json"]); + await git(primary, [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ]); + const checkout = join(root, "linked"); + await git(primary, ["worktree", "add", "--quiet", "-b", "fixture", checkout]); + return { primary, checkout }; +} +async function refuses(run: () => Promise) { + try { + await run(); + throw new Error("Expected refusal"); + } catch (error: unknown) { + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + throw error; + } + expect(error.message).toContain("values omitted"); + expect(error.message).not.toContain(SENTINEL); + expect(error.message).not.toContain(root); + expect(error.cause).toBeUndefined(); + } +} + +test("explicit selection preserves no-docs and missing overlay without dotenv or key lookup", async () => { + const p = await project(); + await writeFile(join(p, ".hack/.env"), `LEGACY=${SENTINEL}`); + await mkdir(join(p, ".hack/.hack.secret.key")); + const before = await readdir(join(p, ".hack")); + for (const overlay of [null, "qa"]) { + expect(await metadata(p, overlay)).toEqual({ + overlay, + overlayExists: false, + effectiveMetadata: {}, + unknownScopes: [], + }); + } + expect(await readdir(join(p, ".hack"))).toEqual(before); +}); + +test("same owner projection preserves scopes, tombstones, empty presence, jobs and inactive declarations", async () => { + const p = await project(); + await layer(p, "hack.env.default.yaml", { + global: { EMPTY: "", DELETE: secure, WIN: secure }, + web: { WIN: SENTINEL }, + host: { HOST: secure }, + job: { JOB: secure }, + inactive: { INACTIVE: secure }, + unknown: { UNKNOWN: secure }, + }); + await layer(p, "hack.env.qa.yaml", { + global: { WIN: secure, DELETE: null }, + web: { EMPTY: null }, + }); + await layer(p, "hack.env.local.yaml", { + global: { EMPTY: "", WIN: SENTINEL }, + }); + await layer(p, "hack.env.qa.local.yaml", { + web: { DELETE: "", WIN: secure }, + }); + const result = await metadata(p, "qa"); + expect(result.overlayExists).toBe(true); + expect(result.unknownScopes).toEqual(["unknown"]); + expect(result.effectiveMetadata.web).toEqual({ + EMPTY: { scope: "global", secret: false }, + DELETE: { scope: "web", secret: false }, + WIN: { scope: "web", secret: true }, + }); + expect(result.effectiveMetadata.job?.JOB).toEqual({ + scope: "job", + secret: true, + }); + expect(result.effectiveMetadata.inactive?.INACTIVE).toEqual({ + scope: "inactive", + secret: true, + }); + expect(result.effectiveMetadata.web?.HOST).toBeUndefined(); + expect( + (await metadata(p, "qa", true, ["web", "host"])).effectiveMetadata.host + ?.HOST + ).toEqual({ scope: "host", secret: true }); + expect(JSON.stringify(result)).not.toContain(SENTINEL); + expect(Object.keys(result).sort()).toEqual([ + "effectiveMetadata", + "overlay", + "overlayExists", + "unknownScopes", + ]); + expect((await metadata(p, null)).effectiveMetadata.web?.DELETE).toEqual({ + scope: "global", + secret: true, + }); +}); + +test("all six layers use live verified primary inputs with checkout-local precedence", async () => { + const { primary, checkout } = await linked(); + await layer(checkout, "hack.env.default.yaml", { + web: { WIN: SENTINEL, BASE: "" }, + }); + await layer(checkout, "hack.env.qa.yaml", { + global: { WIN: secure, OVERLAY: "" }, + }); + await layer(primary, "hack.env.local.yaml", { + web: { WIN: SENTINEL, PRIMARY: "" }, + }); + await layer(primary, "hack.env.qa.local.yaml", { + global: { WIN: secure, PRIMARY_OVERLAY: "" }, + }); + await layer(checkout, "hack.env.local.yaml", { + web: { WIN: SENTINEL, CURRENT: "" }, + }); + await layer(checkout, "hack.env.qa.local.yaml", { + global: { WIN: secure, CURRENT_OVERLAY: "" }, + }); + expect((await metadata(checkout, "qa")).effectiveMetadata.web?.WIN).toEqual({ + scope: "global", + secret: true, + }); + expect( + Object.keys( + (await metadata(checkout, "qa")).effectiveMetadata.web ?? {} + ).sort() + ).toEqual([ + "BASE", + "CURRENT", + "CURRENT_OVERLAY", + "OVERLAY", + "PRIMARY", + "PRIMARY_OVERLAY", + "WIN", + ]); + await layer(primary, "hack.env.local.yaml", { global: { NEW: secure } }); + expect((await metadata(checkout)).effectiveMetadata.web?.NEW).toEqual({ + scope: "global", + secret: true, + }); + expect((await metadata(primary)).effectiveMetadata.web?.NEW).toEqual({ + scope: "global", + secret: true, + }); +}); + +for (const exclusion of ["optout", "ci", "slim", "codex"] as const) { + test(`${exclusion} skips incompatible primary but retains checkout-local layer`, async () => { + const { primary, checkout } = await linked(); + await rm(join(primary, ".hack/hack.project.json")); + await writeFile(join(primary, ".hack/hack.config.json"), SENTINEL); + await layer(checkout, "hack.env.local.yaml", { + global: { CURRENT: secure }, + }); + if (exclusion === "ci") { + process.env.CI = "true"; + } + if (exclusion === "slim" || exclusion === "codex") { + process.env.HACK_EXECUTION_MODE = exclusion; + } + expect( + (await metadata(checkout, null, exclusion !== "optout")).effectiveMetadata + .web?.CURRENT?.secret + ).toBe(true); + }); +} + +test("nested native root never inherits its ancestor checkout's locals", async () => { + const { primary, checkout } = await linked(); + await layer(primary, "hack.env.local.yaml", { global: { FOREIGN: secure } }); + const nested = join(checkout, "nested"); + await mkdir(join(nested, ".hack"), { recursive: true }); + await writeFile(join(nested, ".hack/hack.project.json"), "{}"); + expect((await metadata(nested)).effectiveMetadata).toEqual({}); +}); + +test("invalid exact-root Git linkage refuses instead of inheriting or falling back", async () => { + const p = await project(); + await writeFile(join(p, ".git"), `gitdir: ${SENTINEL}`); + await refuses(() => metadata(p)); +}); + +test("tracked local filename compatibility and non-Git environment hint remain supported", async () => { + const { primary } = await linked(); + await layer( + primary, + "hack.env.local.yaml", + { global: { OLD: secure } }, + "local" + ); + await git(primary, ["add", ".hack/hack.env.local.yaml"]); + await layer(primary, "hack.env.default.local.yaml", { + global: { NEW: secure }, + }); + expect((await metadata(primary)).effectiveMetadata.web).toEqual({ + NEW: { scope: "global", secret: true }, + }); + expect( + (await metadata(primary, "local")).effectiveMetadata.web?.OLD?.secret + ).toBe(true); + const standalone = await project("standalone"); + await layer( + standalone, + "hack.env.local.yaml", + { global: { OLD: secure } }, + "local" + ); + await layer(standalone, "hack.env.default.local.yaml", { + global: { NEW: secure }, + }); + expect((await metadata(standalone)).effectiveMetadata.web).toEqual({ + NEW: { scope: "global", secret: true }, + }); +}); + +for (const kind of [ + "symlink", + "dangling", + "directory", + "unreadable", + "oversized", + "malformed", + "utf8", +] as const) { + test(`selected ${kind} managed input refuses with redacted error`, async () => { + const p = await project(); + const path = join(p, ".hack/hack.env.qa.yaml"); + const target = join(root, SENTINEL); + await writeFile(target, SENTINEL); + if (kind === "symlink" || kind === "dangling") { + await symlink(kind === "symlink" ? target : `${target}-missing`, path); + } else if (kind === "directory") { + await mkdir(path); + } else if (kind === "oversized") { + await writeFile(path, "x".repeat(1024 * 1024 + 1)); + } else if (kind === "utf8") { + await writeFile(path, new Uint8Array([0xff])); + } else { + await writeFile(path, `broken: [${SENTINEL}`); + if (kind === "unreadable") { + await chmod(path, 0); + } + } + await refuses(() => metadata(p, "qa")); + }); +} + +test("redirected .hack and mixed family refuse, arbitrary helper filenames cannot read keys", async () => { + const p = await project(); + await refuses(() => + acquireNativeManagedEnvFile({ + projectRoot: p, + filename: ".hack.secret.key", + }) + ); + await refuses(() => + acquireNativeManagedEnvFile({ projectRoot: p, filename: "../outside" }) + ); + await writeFile(join(p, ".hack/hack.config.json"), SENTINEL); + await refuses(() => metadata(p)); + await rm(join(p, ".hack/hack.config.json")); + const moved = join(root, "moved"); + await mkdir(moved); + await writeFile(join(moved, "hack.project.json"), "{}"); + await rm(join(p, ".hack"), { recursive: true }); + await symlink(moved, join(p, ".hack")); + await refuses(() => metadata(p)); +}); + +test("already cancelled acquisition propagates fixed cancellation without caller reason", async () => { + const p = await project(); + const controller = new AbortController(); + controller.abort(SENTINEL); + await expect( + resolveProjectEnvMetadataForNativeSelection({ + projectRoot: p, + overlay: null, + inheritLocal: true, + declaredWorkloadNames: [], + signal: controller.signal, + }) + ).rejects.toMatchObject({ code: "E_COMPILER_CANCELLED" }); +}); + +test("aggregate metadata expansion is bounded independently of file size", async () => { + const p = await project(); + await layer(p, "hack.env.default.yaml", { + global: Object.fromEntries( + Array.from({ length: 2000 }, (_, i) => [`KEY_${i}`, ""]) + ), + }); + await refuses(() => + metadata( + p, + null, + true, + Array.from({ length: 40 }, (_, i) => `workload-${i}`) + ) + ); +}); + +test("empty managed document retains presence; unrelated local policy is never parsed", async () => { + const p = await project(); + await writeFile(join(p, ".hack/hack.local.json"), `broken ${SENTINEL}`); + await layer(p, "hack.env.qa.yaml", {}); + expect(await metadata(p, "qa", false, [])).toEqual({ + overlay: "qa", + overlayExists: true, + effectiveMetadata: { global: {} }, + unknownScopes: [], + }); + for (const overlay of ["../secret", "qa.local", "QA", ""]) { + await refuses(() => metadata(p, overlay)); + } +}); + +test("primary redirected managed layers refuse and optout preserves current-only selection", async () => { + const { primary, checkout } = await linked(); + await symlink( + join(root, SENTINEL), + join(primary, ".hack/hack.env.local.yaml") + ); + await refuses(() => metadata(checkout)); + expect((await metadata(checkout, null, false)).effectiveMetadata).toEqual({}); +}); + +test("tracked-path inspection disables repository fsmonitor and ignores caller Git redirection", async () => { + const { primary } = await linked(); + const marker = join(root, "fsmonitor-ran"); + const hook = join(root, "fsmonitor.sh"); + await writeFile(hook, `#!/bin/sh\nprintf ran > '${marker}'\n`); + await chmod(hook, 0o700); + await git(primary, ["config", "core.fsmonitor", hook]); + await layer( + primary, + "hack.env.local.yaml", + { global: { LOCAL: secure } }, + "local" + ); + process.env.GIT_DIR = join(root, "foreign"); + process.env.GIT_WORK_TREE = join(root, "foreign"); + process.env.GIT_COMMON_DIR = join(root, "foreign"); + // Untracked local remains base even with a historical environment hint in Git. + expect((await metadata(primary)).effectiveMetadata.web?.LOCAL?.secret).toBe( + true + ); + expect(await Bun.file(marker).exists()).toBe(false); +}); + +test("cancellation interrupts and reaps an in-flight owned Git inspection", async () => { + const { primary } = await linked(); + const bin = join(root, "bin"); + const marker = join(root, "inspection-started"); + await mkdir(bin); + await writeFile( + join(bin, "git"), + `#!/bin/sh\nprintf ready > '${marker}'\nexec /bin/sleep 30\n` + ); + await chmod(join(bin, "git"), 0o700); + process.env.PATH = bin; + const controller = new AbortController(); + const pending = resolveProjectEnvMetadataForNativeSelection({ + projectRoot: primary, + overlay: null, + inheritLocal: true, + declaredWorkloadNames: [], + signal: controller.signal, + }).then( + () => null, + (error: unknown) => error + ); + try { + for ( + let attempt = 0; + attempt < 100 && !(await Bun.file(marker).exists()); + attempt++ + ) { + await Bun.sleep(5); + } + expect(await Bun.file(marker).exists()).toBe(true); + } finally { + controller.abort(SENTINEL); + } + const error = await pending; + expect(error).toMatchObject({ code: "E_COMPILER_CANCELLED" }); + expect(String(error)).not.toContain(SENTINEL); +}); + +test("host-only many-key input stays within the guest output budget across many workloads", async () => { + const p = await project(); + const hostKeys = Object.fromEntries( + Array.from({ length: 4000 }, (_, index) => [`HOST_${index}`, secure]) + ); + const workloads = Array.from({ length: 300 }, (_, index) => `guest-${index}`); + await layer(p, "hack.env.default.yaml", { host: hostKeys }); + const result = await metadata(p, null, false, workloads); + expect(result.unknownScopes).toEqual([]); + expect(Object.keys(result.effectiveMetadata.host ?? {})).toHaveLength(4000); + for (const name of workloads) { + expect(result.effectiveMetadata[name]).toEqual({}); + } + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(1024 * 1024); + expect(JSON.stringify(result)).not.toContain(SENTINEL); + // A declared workload named host still receives its own scope as guest data. + const withHost = await metadata(p, null, false, [...workloads, "host"]); + expect(withHost.effectiveMetadata.host).toEqual( + result.effectiveMetadata.host + ); +}); diff --git a/tests/native-env-plan-transport.test.ts b/tests/native-env-plan-transport.test.ts new file mode 100644 index 000000000..1bb0f6b52 --- /dev/null +++ b/tests/native-env-plan-transport.test.ts @@ -0,0 +1,793 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdtemp, 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 { 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, +}; +const INPUT = new TextEncoder().encode('{"schema_version":1,"name":"fixture"}'); +const CANARY = "wire-private-canary"; +const METADATA = Object.freeze({ + metadata_version: 1, + overlay: "qa", + overlay_exists: true, + workloads: Object.freeze({ + web: Object.freeze({ + API_TOKEN: Object.freeze({ scope: "web", secret: true }), + }), + }), + inactive_scopes: Object.freeze(["retired"]), +}) satisfies NativeEnvMetadata; +const LOCAL_RESOLUTION = { + overlay: "qa", + origin: "checkout_local", + auto_branch: true, + inherit_local: true, + resolution_hash: "b".repeat(64), +} as const; +const DIAGNOSTIC = { + document: "project", + code: "missing_environment_binding", + pointer: "/services/web/environment/API_TOKEN", + message: "Required environment binding is missing.", + line: 1, + column: 1, +} as const; +const ENVIRONMENT_PLAN = { + plan_version: 1, + overlay: "qa", + overlay_exists: true, + complete: true, + workloads: { + web: { + API_TOKEN: { + kind: "managed", + key: "API_TOKEN", + scope: "web", + secret: true, + }, + MODE: { kind: "literal", value: "fixture" }, + retries: { kind: "default", value: "3" }, + }, + }, + warnings: [], + diagnostics: [], +} as const; +const SUCCESS = { + transport_version: 1, + ok: true, + plan: { plan_version: 1, services: { web: {} }, jobs: {} }, + semantic_hash: "a".repeat(64), + declared_workloads: { web: "service", seed: "job" }, + local_resolution: LOCAL_RESOLUTION, + environment_plan: ENVIRONMENT_PLAN, +} as const; +let directory = ""; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "hack-env-plan-transport-")); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function fixture(opts: { + readonly body: string; + readonly protocol?: Readonly>; + readonly onInvocation?: string; +}): Promise { + const path = join(directory, "compiler"); + 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 { ${opts.body} }\n` + ); + await chmod(path, 0o755); + return path; +} + +async function responseFixture(opts: { + readonly response: unknown; + readonly exitCode?: number; +}): Promise { + return await fixture({ + body: `process.stdout.write(${JSON.stringify(JSON.stringify(opts.response))}); process.stderr.write(${JSON.stringify(CANARY)}); process.exitCode=${opts.exitCode ?? 0};`, + }); +} + +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 native compiler 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); +} + +test.each([ + { name: "missing", version: undefined }, + { name: "null", version: null }, + { name: "unsupported", version: 2 }, +])("requires env_plan_version 1 before sending any input: $name", async ({ + version, +}) => { + const receipt = join(directory, "input-receipt"); + const protocolReceipt = join(directory, "protocol-input-receipt"); + const binary = await fixture({ + protocol: { ...PROTOCOL, env_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 [ + () => planNativeConfig({ input: INPUT, envMetadata: METADATA, binary }), + () => + compileNativeConfig({ input: INPUT, binary, requireEnvPlanning: true }), + () => + resolveNativeConfig({ input: INPUT, binary, requireEnvPlanning: 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("older local-resolution compilers still compile and resolve", async () => { + const { env_plan_version, ...protocol } = PROTOCOL; + void env_plan_version; + const legacyResponse = { + transport_version: 1, + ok: true, + plan: { plan_version: 1 }, + semantic_hash: SUCCESS.semantic_hash, + local_resolution: LOCAL_RESOLUTION, + }; + const binary = await fixture({ + protocol, + body: `const request=await Bun.stdin.text(); const result=${JSON.stringify(legacyResponse)}; result.plan.request=request; result.plan.command=process.argv[2]; process.stdout.write(JSON.stringify(result));`, + }); + const compiled = await compileNativeConfig({ input: INPUT, binary }); + const resolved = await resolveNativeConfig({ input: INPUT, binary }); + expect(compiled.ok).toBe(true); + expect(resolved.ok).toBe(true); + if (!(compiled.ok && resolved.ok)) { + throw new Error("Expected backward-compatible compile and resolve success"); + } + expect(compiled.plan.command).toBe("compile"); + expect(compiled.plan.request).toBe(new TextDecoder().decode(INPUT)); + expect(resolved.plan.command).toBe("resolve"); + expect(JSON.parse(String(resolved.plan.request))).toEqual({ + request_version: 1, + project: new TextDecoder().decode(INPUT), + }); + expect(compiled).not.toHaveProperty("declared_workloads"); + expect(resolved).not.toHaveProperty("declared_workloads"); + expect(resolved).not.toHaveProperty("environment_plan"); +}); + +test("preserves immutable original documents, tri-state selection, and PATH-only environment", async () => { + const projectText = '\ufeff{ "name":"first", "name":"second" }\n'; + const primaryText = '\ufeff{"default_overlay":"qa","default_overlay":null}\n'; + const checkoutText = "null\n"; + const input = new TextEncoder().encode(projectText); + const primaryLocal = new TextEncoder().encode(primaryText); + const checkoutLocal = new TextEncoder().encode(checkoutText); + const originals = [ + input.slice(), + primaryLocal.slice(), + checkoutLocal.slice(), + ]; + const originalEnv = process.env.HACK_TEST_NATIVE_PLAN_PRIVATE; + process.env.HACK_TEST_NATIVE_PLAN_PRIVATE = CANARY; + try { + const binary = await fixture({ + body: `const received=JSON.parse(await Bun.stdin.text()); const result=${JSON.stringify(SUCCESS)}; result.plan.received=received; result.plan.arguments=process.argv.slice(2); result.plan.environment=process.env; process.stdout.write(JSON.stringify(result));`, + }); + for (const explicitOverlay of [undefined, null, "qa"]) { + const result = await planNativeConfig({ + input, + primaryLocal, + checkoutLocal, + explicitOverlay, + envMetadata: METADATA, + profiles: ["with spaces", "--unsafe"], + binary, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("Expected environment planning success"); + } + expect(result.plan.received).toEqual({ + request_version: 1, + project: projectText, + primary_local: primaryText, + checkout_local: checkoutText, + ...(explicitOverlay === undefined + ? {} + : { explicit_overlay: explicitOverlay }), + 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.environment_plan).toEqual(ENVIRONMENT_PLAN); + } + expect([input, primaryLocal, checkoutLocal]).toEqual(originals); + } finally { + restoreEnv("HACK_TEST_NATIVE_PLAN_PRIVATE", originalEnv); + } +}); + +test.each([ + "value", + "ciphertext", + "path", +])("rejects extra metadata %s fields before spawning with fixed redaction", async (field) => { + const receipt = join(directory, "spawn-receipt"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + body: `process.stdout.write(${JSON.stringify(JSON.stringify(SUCCESS))});`, + }); + for (const envMetadata of [ + { ...METADATA, [field]: CANARY }, + { + ...METADATA, + workloads: { + web: { API_TOKEN: { scope: "web", secret: true, [field]: CANARY } }, + }, + }, + ]) { + await expectFailure({ + operation: planNativeConfig({ input: INPUT, envMetadata, binary }), + code: "E_CONFIG_METADATA", + message: + "Native environment metadata is invalid or exceeds its budget; values omitted.", + }); + expect(await Bun.file(receipt).exists()).toBe(false); + } +}); + +test("rejects oversized metadata before spawning", async () => { + const receipt = join(directory, "spawn-receipt"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + body: "", + }); + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + envMetadata: { + ...METADATA, + inactive_scopes: ["a".repeat(NATIVE_CONFIG_INPUT_LIMIT)], + }, + binary, + }), + code: "E_CONFIG_METADATA", + message: + "Native environment metadata is invalid or exceeds its budget; values omitted.", + }); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +test("bounds original documents and UTF-8 before spawning", async () => { + const receipt = join(directory, "spawn-receipt"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + body: "", + }); + for (const documents of [ + { input: new Uint8Array(NATIVE_CONFIG_INPUT_LIMIT + 1) }, + { input: INPUT, primaryLocal: new Uint8Array([255]) }, + { input: INPUT, checkoutLocal: new Uint8Array([255]) }, + { + input: INPUT, + explicitOverlay: "a".repeat(NATIVE_CONFIG_INPUT_LIMIT + 1), + }, + ]) { + await expectFailure({ + operation: planNativeConfig({ + ...documents, + envMetadata: METADATA, + binary, + }), + code: "E_CONFIG_INPUT", + }); + expect(await Bun.file(receipt).exists()).toBe(false); + } +}); + +const INVALID_RESPONSES: readonly { + readonly name: string; + readonly response: unknown; + readonly exitCode?: number; +}[] = [ + { + name: "missing report", + response: { ...SUCCESS, environment_plan: undefined }, + }, + { + name: "missing namespace", + response: { ...SUCCESS, declared_workloads: undefined }, + }, + { + name: "invalid namespace kind", + response: { ...SUCCESS, declared_workloads: { web: CANARY } }, + }, + { + name: "invalid namespace name", + response: { ...SUCCESS, declared_workloads: { "../private": "service" } }, + }, + { + name: "report version", + response: { + ...SUCCESS, + environment_plan: { ...ENVIRONMENT_PLAN, plan_version: 2 }, + }, + }, + { + name: "report extra field", + response: { + ...SUCCESS, + environment_plan: { ...ENVIRONMENT_PLAN, ciphertext: CANARY }, + }, + }, + { + name: "managed binding value", + response: { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + workloads: { + web: { + API_TOKEN: { + ...ENVIRONMENT_PLAN.workloads.web.API_TOKEN, + value: CANARY, + }, + }, + }, + }, + }, + }, + { + name: "unknown binding kind", + response: { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + workloads: { web: { MODE: { kind: CANARY } } }, + }, + }, + }, + { + name: "invalid binding key", + response: { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + workloads: { + web: { "invalid-key": { kind: "literal", value: CANARY } }, + }, + }, + }, + }, + { + name: "invalid managed scope", + response: { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + workloads: { + web: { + API_TOKEN: { + ...ENVIRONMENT_PLAN.workloads.web.API_TOKEN, + scope: "../private", + }, + }, + }, + }, + }, + }, + { + name: "missing diagnostic document", + response: { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + warnings: [{ ...DIAGNOSTIC, document: undefined }], + }, + }, + }, + { + name: "invalid diagnostic document", + response: { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + warnings: [{ ...DIAGNOSTIC, document: CANARY }], + }, + }, + }, + { + name: "diagnostics with complete report", + response: { + ...SUCCESS, + environment_plan: { ...ENVIRONMENT_PLAN, diagnostics: [DIAGNOSTIC] }, + }, + }, + { + name: "incomplete report without diagnostics", + response: { + ...SUCCESS, + environment_plan: { ...ENVIRONMENT_PLAN, complete: false }, + }, + exitCode: 1, + }, + { + name: "report overlay mismatch", + response: { + ...SUCCESS, + environment_plan: { ...ENVIRONMENT_PLAN, overlay: "prod" }, + }, + }, + { + name: "resolution overlay mismatch", + response: { + ...SUCCESS, + local_resolution: { ...LOCAL_RESOLUTION, overlay: "prod" }, + }, + }, + { + name: "overlay existence mismatch", + response: { + ...SUCCESS, + environment_plan: { ...ENVIRONMENT_PLAN, overlay_exists: false }, + }, + }, + { + name: "missing selected workload", + response: { + ...SUCCESS, + environment_plan: { ...ENVIRONMENT_PLAN, workloads: {} }, + }, + }, + { + name: "extra selected workload", + response: { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + workloads: { ...ENVIRONMENT_PLAN.workloads, seed: {} }, + }, + }, + }, + { + name: "selected workload kind mismatch", + response: { ...SUCCESS, declared_workloads: { web: "job" } }, + }, + { + name: "selected workload in both kinds", + response: { ...SUCCESS, plan: { ...SUCCESS.plan, jobs: { web: {} } } }, + }, + { + name: "missing selection record", + response: { ...SUCCESS, plan: { plan_version: 1 } }, + }, + { name: "complete report exits one", response: SUCCESS, exitCode: 1 }, + { + name: "incomplete report exits zero", + response: { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + complete: false, + diagnostics: [DIAGNOSTIC], + }, + }, + }, + { name: "unexpected exit", response: SUCCESS, exitCode: 2 }, +]; + +test.each([ + ...INVALID_RESPONSES, +])("rejects malformed plan response: $name", async ({ response, exitCode }) => { + const binary = await responseFixture({ response, exitCode }); + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + envMetadata: METADATA, + binary, + }), + code: "E_COMPILER_RESPONSE", + }); +}); + +test("accepts an incomplete ok:true plan with exit one and structured diagnostics", async () => { + const response = { + ...SUCCESS, + environment_plan: { + ...ENVIRONMENT_PLAN, + complete: false, + diagnostics: [DIAGNOSTIC], + }, + }; + const binary = await responseFixture({ response, exitCode: 1 }); + expect( + await planNativeConfig({ input: INPUT, envMetadata: METADATA, binary }) + ).toEqual(response); +}); + +test("strips raw envelope, local-resolution, and diagnostic extra fields", async () => { + const binary = await responseFixture({ + response: { + ...SUCCESS, + private_payload: CANARY, + local_resolution: { ...LOCAL_RESOLUTION, path: CANARY }, + environment_plan: { + ...ENVIRONMENT_PLAN, + warnings: [{ ...DIAGNOSTIC, ciphertext: CANARY }], + }, + }, + }); + const result = await planNativeConfig({ + input: INPUT, + envMetadata: METADATA, + binary, + }); + expect(result).toEqual({ + ...SUCCESS, + environment_plan: { ...ENVIRONMENT_PLAN, warnings: [DIAGNOSTIC] }, + }); + expect(JSON.stringify(result)).not.toContain(CANARY); + expect(result).not.toHaveProperty("envelope"); +}); + +test("preserves an authored __proto__ binding as an own JSON property", async () => { + const bindings = Object.fromEntries([ + ["__proto__", { kind: "literal", value: "fixture-proto" }], + ]); + const environmentPlan = { + ...ENVIRONMENT_PLAN, + workloads: { web: bindings }, + }; + const binary = await responseFixture({ + response: { ...SUCCESS, environment_plan: environmentPlan }, + }); + const result = await planNativeConfig({ + input: INPUT, + envMetadata: METADATA, + binary, + }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("Expected a valid authored binding report"); + } + const reportedBindings = result.environment_plan.workloads.web; + expect(Object.hasOwn(reportedBindings ?? {}, "__proto__")).toBe(true); + expect(JSON.parse(JSON.stringify(result.environment_plan))).toEqual( + environmentPlan + ); +}); + +test.each([ + "stdout", + "stderr", +])("bounds plan %s and reaps the overflowing child", async (stream) => { + const pidPath = join(directory, "pid"); + const limit = stream === "stdout" ? 8 * 1024 * 1024 : 64 * 1024; + const binary = await fixture({ + body: `await Bun.write(${JSON.stringify(pidPath)}, String(process.pid)); process.${stream}.write('x'.repeat(${limit + 1})); await Bun.sleep(10000);`, + }); + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + envMetadata: METADATA, + binary, + }), + code: "E_COMPILER_BUDGET", + message: "Native configuration I/O exceeds its budget.", + }); + const pid = Number(await Bun.file(pidPath).text()); + expect(() => process.kill(pid, 0)).toThrow(); +}); + +async function waitForPid(path: string): Promise { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (await Bun.file(path).exists()) { + const pid = Number(await Bun.file(path).text()); + if (Number.isSafeInteger(pid) && pid > 0) { + return pid; + } + } + await Bun.sleep(10); + } + throw new Error( + "Owned plan compiler did not publish its PID within the test budget" + ); +} + +test("cancellation reaps the owned plan child and pre-cancellation never spawns", async () => { + const pidPath = join(directory, "pid"); + const receipt = join(directory, "spawn-receipt"); + const binary = await fixture({ + onInvocation: `await Bun.write(${JSON.stringify(receipt)}, 'spawned');`, + body: `await Bun.stdin.text(); await Bun.write(${JSON.stringify(pidPath)}, String(process.pid)); await Bun.sleep(10000);`, + }); + const controller = new AbortController(); + const operation = planNativeConfig({ + input: INPUT, + envMetadata: METADATA, + binary, + signal: controller.signal, + }).catch((error: unknown) => error); + let pid = 0; + try { + pid = await waitForPid(pidPath); + } finally { + controller.abort(); + await expectFailure({ operation, code: "E_COMPILER_CANCELLED" }); + } + expect(() => process.kill(pid, 0)).toThrow(); + await rm(receipt); + await expectFailure({ + operation: planNativeConfig({ + input: INPUT, + envMetadata: METADATA, + binary, + signal: controller.signal, + }), + code: "E_COMPILER_CANCELLED", + }); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +async function descendantFixture(): Promise<{ + readonly binary: string; + readonly parentPidPath: string; + readonly descendantPidPath: string; +}> { + const parentPidPath = join(directory, "parent-pid"); + const descendantPidPath = join(directory, "descendant-pid"); + const descendantScript = join(directory, "descendant.ts"); + await Bun.write( + descendantScript, + `await Bun.write(${JSON.stringify(descendantPidPath)}, String(process.pid)); await Bun.sleep(10000);` + ); + const binary = await fixture({ + body: `await Bun.stdin.text(); await Bun.write(${JSON.stringify(parentPidPath)}, String(process.pid)); Bun.spawn([${JSON.stringify(process.execPath)}, ${JSON.stringify(descendantScript)}], { env:{PATH:'/usr/bin:/bin'}, stdin:'ignore', stdout:'inherit', stderr:'inherit' }); await Bun.sleep(10000);`, + }); + return { binary, parentPidPath, descendantPidPath }; +} + +async function withinTestBudget(operation: Promise): Promise { + let timer: ReturnType | undefined; + const deadline = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject( + new Error("Owned plan operation exceeded the test cleanup budget") + ); + }, 2000); + }); + try { + return await Promise.race([operation, deadline]); + } finally { + clearTimeout(timer); + } +} + +async function stopOwnedFixturePid(path: string): Promise { + if (!(await Bun.file(path).exists())) { + return; + } + const pid = Number(await Bun.file(path).text()); + if (!(Number.isSafeInteger(pid) && pid > 0)) { + throw new Error("Owned process fixture published an invalid PID"); + } + try { + process.kill(pid, "SIGKILL"); + } catch (error: unknown) { + if ( + !(error instanceof Error && "code" in error && error.code === "ESRCH") + ) { + throw error; + } + } +} + +async function expectProcessGone(pid: number): Promise { + const deadline = Date.now() + 1000; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch { + return; + } + await Bun.sleep(10); + } + throw new Error("Owned compiler process survived completed cancellation"); +} + +test("cancellation kills the owned plan process group when a descendant holds its pipes", async () => { + const { binary, parentPidPath, descendantPidPath } = + await descendantFixture(); + const controller = new AbortController(); + const operation = planNativeConfig({ + input: INPUT, + envMetadata: METADATA, + binary, + signal: controller.signal, + }).catch((error: unknown) => error); + try { + const parentPid = await waitForPid(parentPidPath); + const descendantPid = await waitForPid(descendantPidPath); + controller.abort(); + await expectFailure({ + operation: withinTestBudget(operation), + code: "E_COMPILER_CANCELLED", + }); + await expectProcessGone(parentPid); + await expectProcessGone(descendantPid); + } finally { + controller.abort(); + await stopOwnedFixturePid(descendantPidPath); + await stopOwnedFixturePid(parentPidPath); + await operation; + } +}); + +test("timeout kills the owned plan process group when a descendant holds its pipes", async () => { + const { binary, parentPidPath, descendantPidPath } = + await descendantFixture(); + const operation = planNativeConfig({ + input: INPUT, + envMetadata: METADATA, + binary, + timeoutMs: 1000, + }).catch((error: unknown) => error); + try { + const parentPid = await waitForPid(parentPidPath); + const descendantPid = await waitForPid(descendantPidPath); + await expectFailure({ + operation: withinTestBudget(operation), + code: "E_COMPILER_TIMEOUT", + }); + await expectProcessGone(parentPid); + await expectProcessGone(descendantPid); + } finally { + await stopOwnedFixturePid(descendantPidPath); + await stopOwnedFixturePid(parentPidPath); + await operation; + } +});