diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c711df9b..4bf702cb2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,8 +40,8 @@ jobs: "$RUNNER_TEMP/config-schema/bin/python" scripts/check-config-schema.py - name: Test transport, projections and compiled bundle without host toolchains run: | - bunx ultracite check scripts/build-config-compiler.ts scripts/check-config-compiler-cli.ts tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts - bun test tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts + bunx ultracite check scripts/build-config-compiler.ts scripts/check-config-compiler-cli.ts tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts tests/native-project-inputs.test.ts tests/native-project-inputs-acquisition-race.test.ts + bun test tests/config-compiler-build.test.ts tests/native-config-compiler.test.ts tests/native-config-command.test.ts tests/native-config-dto.test.ts tests/native-project-inputs.test.ts tests/native-project-inputs-acquisition-race.test.ts bun run --cwd packages/cli typecheck bun run build bun scripts/check-config-compiler-cli.ts diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index d7aef58d4..6ea9103d8 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -16,8 +16,8 @@ mise exec -- scripts/build-native-candidate.sh /absolute/new/hack-native-bundle The destination must not exist. The bundle contains `hack-native`, the static Linux ARM64 `hack-relay-guest`, compiled normal CLI `hack-cli`, the `hack-v5` entrypoint, -the compiled `hack-config-compiler` and generated `hack.project.schema.json`, -provider pins, this guide, `SHA256SUMS`, and one content-addressed shared MCP bundle +the compiled `hack-config-compiler`, generated `hack.project.schema.json` and +`hack.local.schema.json`, provider pins, this guide, `SHA256SUMS`, and one content-addressed shared MCP bundle under `mcp/BUNDLE_ID/`. Its manifest and native adapter, owner, and compiled backend are included in the outer checksums. The relay uses the committed guest Cargo lockfile and Zig linker wrapper, with a separate build @@ -39,12 +39,13 @@ selections, set `artifact` to this bundled file's absolute path and `artifact_sha256` to its entry in `SHA256SUMS`; the runtime verifies it again before delivery. The `native-stream-relay` feature does not replace this dependency relay. -The compiler and generated schema stay beside `hack-cli` as one checksummed pair. -Packaging and installation refuse a partial, changed, or aliased pair. The compiler -is executable; the schema is private data. Older bundles without either file remain -installable and selectable. An existing channel with an older retained installer -must explicitly run `upgrade-manager` with the reviewed installer before selecting -a bundle with the new pair, or use a fresh channel root. This upgrades the manager; +The compiler and both generated schemas stay beside `hack-cli` as one checksummed +group. Packaging and installation refuse partial, changed, or aliased groups. The +compiler is executable; the schemas are private data. Older bundles with no compiler +or only the complete compiler/project-schema pair remain installable and selectable. +A local schema always requires the compiler and project schema alongside it. +An existing channel with an older retained installer must explicitly run `upgrade-manager` with the reviewed installer before selecting +a bundle with the new group, or use a fresh channel root. This upgrades the manager; it does not migrate the retained runtime homes. Shared MCP remains opt-in. Select the verified nested bundle with the existing diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 24919146b..09807dacb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -490,7 +490,7 @@ hack branch open [options] ## `hack config` -Read/write legacy config or validate an explicit native project file +Read/write legacy config or validate native project configuration ### Usage @@ -504,7 +504,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 an explicit native project file without starting workloads | +| `hack config validate` | Validate native configuration and selected local overlays without starting workloads | ### Options @@ -571,7 +571,7 @@ hack config set [options] ## `hack config validate` -Validate an explicit native project file without starting workloads +Validate native configuration and selected local overlays without starting workloads ### Usage @@ -579,14 +579,16 @@ Validate an explicit native project file without starting workloads hack config validate [options] ``` -Uses the matching bundled Rust compiler. This experimental command does not discover a project, resolve secrets, or adopt native configuration for runtime commands. +Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads. ### Options | Option | Description | | --- | --- | -| `--file ` | Required native project JSON file | +| `--file ` | Validate only this native project JSON file, without discovery or local overrides | | `--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 | diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md index ca436db34..42e083c4d 100644 --- a/docs/reference/native-config-compiler.md +++ b/docs/reference/native-config-compiler.md @@ -1,10 +1,11 @@ -# Native config compiler foundation +# Native configuration validation This is an experimental, pure compiler for a bounded subset of the planned -`.hack/hack.project.json` format. The compiler does not discover projects, execute workloads, +`.hack/hack.project.json` format. The pure compiler does not discover projects, execute workloads, import Compose, migrate data, decrypt environment values, perform host admission, or change how existing projects run. A successful compile is syntax and semantic -validation, not backend capability or application acceptance. +validation, not backend capability or application acceptance. The CLI can acquire +the selected project and permitted local settings for offline resolution. The CLI recognizes this filename as a project boundary. Native runtime and adoption are not enabled yet: legacy project commands refuse with @@ -76,7 +77,12 @@ versions refuse; omitted fields retain their documented defaults. - 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. - Local override files are not accepted by this compiler. + Project null refuses; null is supported only in local settings and explicit + command selection. +- Project `worktree.auto_branch` and `worktree.inherit_local` are strict booleans, + both defaulting to true. They appear in the normalized plan. This validation + command uses inheritance policy; it does not create branch instances or execute + `auto_branch` behavior. - Dependencies are `{service:"db",condition:"started"|"ready"}` or `{job:"init",condition:"completed"}`. References must match the declared kind. Ready dependencies require explicit readiness. Services and jobs share one name @@ -102,26 +108,38 @@ must be absolute POSIX container paths without `..`. Routes, shutdown/restart policies, host hooks/processes, endpoint references, network/security/resources, cache protocols, backend options, arbitrary extensions, -and local/worktree policy are not yet implemented. They refuse rather than being +and local settings other than environment selection are not yet implemented. They refuse rather than being silently dropped. This foundation does not replace the full native contract or qualify a migrated advanced project. ## Protocol and diagnostics -The current CLI exposes explicit validation only: +The CLI supports project-aware and explicit-document validation: ```sh +hack config validate --json +hack config validate --path /path/to/native-project --env base --json hack config validate --file .hack/hack.project.json hack config validate --file .hack/hack.project.json --profile dev,test --json ``` -`--file` is required; this command does not switch project discovery or runtime -execution to the native format. `--json` returns the normalized plan, including +Without `--file`, discovery selects a native project without touching the registry. +Legacy or absent projects refuse; mixed active inputs refuse with +`E_NATIVE_PROJECT_CONFLICT`. `--path` changes the discovery start. `--env base` +explicitly selects base; another canonical name selects that overlay. This selects +a name only: overlay existence, managed metadata, keys and required references are +not inspected, and no env values are read. Runtime/adoption commands remain fenced. + +`--file` validates only that document, ignores all local files and performs no +project discovery. It cannot be combined with `--path` or `--env`. +`--json` returns the authored normalized plan, including authored public literals and commands. Those values are intentionally visible; diagnostic redaction does not turn the plan into a secret-safe storage format. - `hack-config-compiler --protocol` emits - `{"transport_version":1,"authored_version":1,"plan_version":1}`. + `{"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. - `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. @@ -133,8 +151,46 @@ diagnostic redaction does not turn the plan into a secret-safe storage format. location; errors for a missing property or CLI-selected profile may point to its containing object. Input contents and parser excerpts never appear in messages. - Invalid invocation exits 2 with fixed usage on stderr and no JSON on stdout. +- `hack-config-compiler resolve [--profile NAME]...` reads a versioned request: + `{request_version:1,project:"original JSON text",primary_local?:"original JSON text",checkout_local?:"original JSON text",explicit_overlay?:null|string}`. + JSON texts retain duplicate keys until Rust checks each document. Each document + is limited to 1 MiB, their combined text to 3 MiB, and the encoded request to + 20 MiB to allow JSON escaping. Diagnostics add `document` identifying `project`, + `primary_local`, `checkout_local` or `request`. - `hack-config-compiler generate DIR` writes deterministic - `hack.project.schema.json` (2020-12) and `native-config.ts` projections. + `hack.project.schema.json`, `hack.local.schema.json` (2020-12) and + `native-config.ts` projections. + +## Local settings and worktrees + +The optional `.hack/hack.local.json` is a separate versioned document: + +```json +{"schema_version":1,"environment":{"default_overlay":null}} +``` + +Only `environment.default_overlay` is supported in this slice. Omission inherits; +null selects base; a canonical name selects that overlay. Other fields, workload +definitions, unversioned documents, duplicate keys and unknown versions refuse. +The effective selection is project default/base, then verified primary local, +current checkout local, then explicit `--env`. Each later present value wins. + +Primary inheritance requires a verified linked Git worktree in the same repository +family and a primary checkout with native inputs. Different input families refuse; +redirected/nonregular input files refuse. `inherit_local:false`, CI and slim mode +exclude primary reads. Current checkout local settings remain available. Files are +read in place; no primary files, secrets, keys or generated state are copied. +Native projects nested below a Git checkout root currently use only their own +local settings; primary inheritance requires the project root to be the Git root. + +Success adds `local_resolution` with selected `overlay` (null means base), `origin`, +worktree policy and `resolution_hash`. Local settings do not rewrite the authored +plan or its `semantic_hash`. The separate hash binds normalized supplied local +documents and explicit selection, including missing versus null. Neither hash +proves an atomic multi-file snapshot or provides an admission/freshness fence. +Future apply must recheck private input generations. Adding defaulted worktree +policy changes hashes relative to the earlier experimental compiler; do not reuse +historical compiler hashes as resource identities. Duplicate keys, including escaped-equivalent keys in nested objects and arrays, are rejected before map insertion. Graph cycle checking is iterative. Serialization diff --git a/packages/config-compiler/README.md b/packages/config-compiler/README.md new file mode 100644 index 000000000..30ee7e208 --- /dev/null +++ b/packages/config-compiler/README.md @@ -0,0 +1,83 @@ +# Pure native configuration compiler + +This standalone package validates an experimental subset of native Hack project +configuration. It performs no discovery, file acquisition, runtime admission, +decryption, process execution, or host-path resolution. The CLI owns acquisition +of verified document bytes. See the public +[compiler reference](../../docs/reference/native-config-compiler.md) for the +supported project grammar and remaining execution boundaries. + +## Local resolution protocol + +`hack-config-compiler --protocol` advertises `transport_version`, +`authored_version`, `plan_version`, `resolve_version`, and `local_version`, all `1`. +Existing `compile [--profile NAME]...` remains context-free. The new +`resolve [--profile NAME]...` operation accepts one UTF-8 JSON request on stdin: + +```json +{ + "request_version": 1, + "project": "{\"schema_version\":1,\"name\":\"example\"}", + "primary_local": "{\"schema_version\":1}", + "checkout_local": "{\"schema_version\":1,\"environment\":{\"default_overlay\":\"qa\"}}", + "explicit_overlay": null +} +``` + +`project` is required; the remaining document fields and `explicit_overlay` are +optional. Embedded documents are original JSON text, not pre-parsed objects. +Recursive duplicate keys, unknown fields, unsupported versions, and invalid shapes +are rejected. Each document is limited to 1 MiB, their combined decoded size to +3 MiB, and the encoded request to 20 MiB. All JSON parsing has a depth budget of 64. +These are parser resource bounds, not runtime workload limits. + +A local document permits only `schema_version: 1` and optional `environment`, +which permits only `default_overlay`. Omission inherits, `null` selects base, +and a string selects a canonical overlay matching `[a-z0-9]+(?:-[a-z0-9]+)*`. +No normalization silently changes names. + +The project supports optional `worktree` with strict boolean `auto_branch` and +`inherit_local`, each defaulting to `true`. Resolution applies the project overlay, +then primary local when inheritance is enabled, then checkout local, then an +explicit selection. Every supplied local document is validated, even a primary +ignored by `inherit_local: false`. The compiler reports policy; it does not create +branches or read Git metadata. + +Successful output contains `transport_version: 1`, `ok: true`, the authored `plan` +and `semantic_hash`, plus `local_resolution` with `overlay`, `origin`, +`auto_branch`, `inherit_local`, and `resolution_hash`. Origin is `project`, +`primary_local`, `checkout_local`, or `explicit`. Locals never alter the authored +plan or its semantic hash. The resolution hash binds the authored hash, normalized +supplied locals (including shadowed or opted-out primary input), and explicit +selection presence/value. Formatting and key order do not affect either hash; +changing a permitted local selection affects the resolution hash even when another +layer shadows it. Host acquisition paths and decrypted secrets never enter this resolver. Authored +literal environment values remain explicit public plan data, just as in compile. + +The normalized plan now materializes defaulted `worktree` policy, so authored +hashes from the earlier experimental compiler change. `plan_version: 1` remains +experimental; this package does not promise compatibility with cached plans from +the earlier prototype. + +Failures exit `1` and return `ok: false` with diagnostics containing `document` +(`project`, `primary_local`, `checkout_local`, or `request`) and fixed `code`, +`message`, JSON `pointer`, `line`, and `column`. Values and parser error text are +not echoed. Pointers necessarily include authored key names; consumers must quote +or escape them for terminal display. Unsupported command arguments exit `2` with +fixed usage text on stderr. Success exits `0`. + +## Generated contracts and checks + +`generate ` emits deterministic `hack.project.schema.json`, +`hack.local.schema.json`, and `native-config.ts` from Rust types. The schemas use +JSON Schema 2020-12. The shared project/local shape corpora are consumed by Rust +acceptance tests and the independent schema validator; graph semantic validation +remains separate from structural schema acceptance. + +Use Rust 1.97.1 with the locked dependencies. From the repository root: + +```sh +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 +``` diff --git a/packages/config-compiler/generated/hack.local.schema.json b/packages/config-compiler/generated/hack.local.schema.json new file mode 100644 index 000000000..921f89fa6 --- /dev/null +++ b/packages/config-compiler/generated/hack.local.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "LocalConfig", + "type": "object", + "properties": { + "environment": { + "$ref": "#/$defs/LocalEnvironment", + "default": {} + }, + "schema_version": { + "type": "integer", + "format": "uint32", + "maximum": 1, + "minimum": 1 + } + }, + "additionalProperties": false, + "required": [ + "schema_version" + ], + "$defs": { + "LocalEnvironment": { + "type": "object", + "properties": { + "default_overlay": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + } + }, + "additionalProperties": false + } + } +} diff --git a/packages/config-compiler/generated/hack.project.schema.json b/packages/config-compiler/generated/hack.project.schema.json index dba1d2d35..61dfab448 100644 --- a/packages/config-compiler/generated/hack.project.schema.json +++ b/packages/config-compiler/generated/hack.project.schema.json @@ -446,6 +446,21 @@ } }, "type": "object" + }, + "WorktreePolicy": { + "additionalProperties": false, + "description": "Authored worktree policy; neither flag grants runtime admission or cleanup authority.", + "properties": { + "auto_branch": { + "default": true, + "type": "boolean" + }, + "inherit_local": { + "default": true, + "type": "boolean" + } + }, + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -498,6 +513,13 @@ }, "default": {}, "type": "object" + }, + "worktree": { + "$ref": "#/$defs/WorktreePolicy", + "default": { + "auto_branch": true, + "inherit_local": true + } } }, "required": [ diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts index 46dac8290..224eabda8 100644 --- a/packages/config-compiler/generated/native-config.ts +++ b/packages/config-compiler/generated/native-config.ts @@ -1,4 +1,5 @@ // Generated by hack-config-compiler; do not edit. +export type WorktreePolicy = { auto_branch?: boolean, inherit_local?: boolean, }; export type SourceMode = "host-mounted"; export type Source = { root?: string, mode?: SourceMode, }; export type EnvironmentSelection = { default_overlay?: string, }; @@ -16,7 +17,15 @@ export type JobCondition = "completed"; export type Dependency = { service: string, condition: ServiceCondition, } | { job: string, condition: JobCondition, }; export type Readiness = { "kind": "exec", command: Command, interval: string, timeout: string, retries: number, } | { "kind": "http", port: number, path: string, interval: string, timeout: string, retries: number, } | { "kind": "tcp", port: number, interval: string, timeout: string, retries: number, }; export type Workload = { image?: string, build?: Build, command?: Command, working_directory?: string, mounts?: Array, environment?: { [key in string]: EnvironmentValue }, depends_on?: Array, profiles?: Array, readiness?: Readiness, }; -export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, }; -export type Plan = { plan_version: 1, name: string, source: Source, environment: EnvironmentSelection, selected_profiles: Array, storage: { [key in string]: Storage }, services: { [key in string]: Workload }, jobs: { [key in string]: Workload }, }; +export type Project = { schema_version: 1, name: string, source?: Source, services?: { [key in string]: Workload }, jobs?: { [key in string]: Workload }, storage?: { [key in string]: Storage }, profiles?: Array, environment?: EnvironmentSelection, worktree?: WorktreePolicy, }; +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 LocalEnvironment = { default_overlay?: string | null, }; +export type LocalConfig = { schema_version: 1, environment?: LocalEnvironment, }; +export type ResolveRequest = { request_version: 1, project: string, primary_local?: string, checkout_local?: string, explicit_overlay?: string | null, }; +export type DocumentRole = "project" | "primary_local" | "checkout_local" | "request"; +export type ResolveDiagnostic = { document: DocumentRole, code: string, message: string, pointer: string, line: number, column: number, }; +export type OverlayOrigin = "project" | "primary_local" | "checkout_local" | "explicit"; +export type LocalResolution = { overlay: string | null, origin: OverlayOrigin, auto_branch: boolean, inherit_local: boolean, resolution_hash: string, }; +export type ResolveResult = { transport_version: 1, ok: true, plan: Plan, semantic_hash: string, local_resolution: LocalResolution, } | { transport_version: 1, ok: false, diagnostics: Array, }; diff --git a/packages/config-compiler/src/json.rs b/packages/config-compiler/src/json.rs index 12f9d6ec6..4510a9868 100644 --- a/packages/config-compiler/src/json.rs +++ b/packages/config-compiler/src/json.rs @@ -18,7 +18,11 @@ struct Parser<'a> { } pub fn parse(bytes: &[u8]) -> Result { - if bytes.len() > MAX_INPUT_BYTES { + parse_with_limit(bytes, MAX_INPUT_BYTES) +} + +pub(crate) fn parse_with_limit(bytes: &[u8], limit: usize) -> Result { + if bytes.len() > limit { return Err(Diagnostic::new("input_too_large", "", 1, 1)); } let text = std::str::from_utf8(bytes).map_err(|_| Diagnostic::new("invalid_utf8", "", 1, 1))?; diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs index 17de71a44..1a7982b46 100644 --- a/packages/config-compiler/src/lib.rs +++ b/packages/config-compiler/src/lib.rs @@ -1,6 +1,8 @@ //! Pure, bounded native configuration compiler. It performs no host admission or secret lookup. mod json; +pub mod local; pub mod model; +mod shape; mod validate; pub use json::MAX_INPUT_BYTES; use model::{Plan, Project}; @@ -32,6 +34,7 @@ impl Diagnostic { } fn diagnostic_message(code: &str) -> &'static str { match code { + "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.", "invalid_json" => "Configuration is not a complete valid JSON document.", @@ -119,6 +122,7 @@ fn compile_inner(bytes: &[u8], profiles: &[String]) -> Result<(Plan, String), Di { return Err(at("unsupported_version", "/schema_version")); } + shape::project(&document)?; let project: Project = serde_path_to_error::deserialize(document.value.clone()).map_err(|error| { let mut pointer = String::new(); @@ -177,6 +181,7 @@ 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 = [ + WorktreePolicy::decl(&cfg), SourceMode::decl(&cfg), Source::decl(&cfg), EnvironmentSelection::decl(&cfg), @@ -198,6 +203,14 @@ pub fn artifacts() -> Result<(String, String), serde_json::Error> { Plan::decl(&cfg), Diagnostic::decl(&cfg), CompileResult::decl(&cfg), + local::LocalEnvironment::decl(&cfg), + local::LocalConfig::decl(&cfg), + local::ResolveRequest::decl(&cfg), + local::DocumentRole::decl(&cfg), + local::ResolveDiagnostic::decl(&cfg), + local::OverlayOrigin::decl(&cfg), + local::LocalResolution::decl(&cfg), + local::ResolveResult::decl(&cfg), ]; Ok(( schema, @@ -213,5 +226,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}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1}) } diff --git a/packages/config-compiler/src/local.rs b/packages/config-compiler/src/local.rs new file mode 100644 index 000000000..a09ece800 --- /dev/null +++ b/packages/config-compiler/src/local.rs @@ -0,0 +1,353 @@ +//! Pure local input resolution. Callers acquire verified documents; no paths or secrets enter here. +use crate::{Diagnostic, compile_inner, diagnostic_at, json, model::Plan, validate::overlay_name}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; +use ts_rs::TS; + +pub const MAX_REQUEST_BYTES: usize = 20 * 1024 * 1024; +pub const MAX_DOCUMENT_BYTES: usize = 3 * json::MAX_INPUT_BYTES; + +fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + T::deserialize(d).map(Some) +} +fn overlay<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + Option::::deserialize(d).map(Some) +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct LocalEnvironment { + #[serde( + default, + deserialize_with = "overlay", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "Option", regex(pattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$"))] + #[ts(optional = nullable, as = "Option")] + pub default_overlay: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct LocalConfig { + #[schemars(range(min = 1, max = 1))] + #[ts(type = "1")] + pub schema_version: u32, + #[serde(default)] + #[ts(optional, as = "Option")] + pub environment: LocalEnvironment, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct ResolveRequest { + #[schemars(range(min = 1, max = 1))] + #[ts(type = "1")] + pub request_version: u32, + pub project: String, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub primary_local: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub checkout_local: Option, + #[serde( + default, + deserialize_with = "overlay", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "Option", regex(pattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$"))] + #[ts(optional = nullable, as = "Option")] + pub explicit_overlay: Option>, +} + +#[derive(Debug, Clone, Copy, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum DocumentRole { + Project, + PrimaryLocal, + CheckoutLocal, + Request, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +pub struct ResolveDiagnostic { + pub document: DocumentRole, + #[serde(flatten)] + pub diagnostic: Diagnostic, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum OverlayOrigin { + Project, + PrimaryLocal, + CheckoutLocal, + Explicit, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +pub struct LocalResolution { + pub overlay: Option, + pub origin: OverlayOrigin, + pub auto_branch: bool, + pub inherit_local: bool, + pub resolution_hash: String, +} +#[derive(Debug, Serialize, JsonSchema, TS)] +#[serde(untagged)] +pub enum ResolveResult { + Success { + #[ts(type = "1")] + transport_version: u32, + #[ts(type = "true")] + ok: bool, + plan: Box, + semantic_hash: String, + local_resolution: LocalResolution, + }, + Failure { + #[ts(type = "1")] + transport_version: u32, + #[ts(type = "false")] + ok: bool, + diagnostics: Vec, + }, +} +impl ResolveResult { + pub fn failure(document: DocumentRole, diagnostic: Diagnostic) -> Self { + Self::Failure { + transport_version: 1, + ok: false, + diagnostics: vec![ResolveDiagnostic { + document, + diagnostic, + }], + } + } +} +fn with_role(document: DocumentRole, diagnostic: Diagnostic) -> ResolveDiagnostic { + ResolveDiagnostic { + document, + diagnostic, + } +} +fn decode( + document: &json::Document, + role: DocumentRole, +) -> Result { + serde_path_to_error::deserialize(document.value.clone()).map_err(|error| { + let mut pointer = String::new(); + for segment in error.path().iter() { + match segment { + serde_path_to_error::Segment::Seq { index } => { + pointer = json::child(&pointer, &index.to_string()) + } + serde_path_to_error::Segment::Map { key } + | serde_path_to_error::Segment::Enum { variant: key } => { + pointer = json::child(&pointer, key) + } + _ => {} + } + } + let code = if error.inner().to_string().starts_with("unknown field") { + "unknown_field" + } else { + "invalid_shape" + }; + with_role(role, diagnostic_at(&document.positions, code, &pointer)) + }) +} +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 + .get("schema_version") + .is_some_and(|v| v.is_u64() && v.as_u64() != Some(1)) + { + return Err(with_role( + role, + diagnostic_at( + &document.positions, + "unsupported_version", + "/schema_version", + ), + )); + } + crate::shape::object(&document, "").map_err(|d| with_role(role, d))?; + if document.value.get("environment").is_some() { + crate::shape::object(&document, "/environment").map_err(|d| with_role(role, d))?; + } + let local: LocalConfig = decode(&document, role)?; + if local + .environment + .default_overlay + .as_ref() + .and_then(|v| v.as_ref()) + .is_some_and(|name| !overlay_name(name)) + { + return Err(with_role( + role, + diagnostic_at( + &document.positions, + "invalid_name", + "/environment/default_overlay", + ), + )); + } + Ok(local) +} + +/// Resolve only the explicitly supplied original documents. No discovery, acquisition or decryption. +pub fn resolve(bytes: &[u8], profiles: &[String]) -> ResolveResult { + match resolve_inner(bytes, profiles) { + Ok((plan, semantic_hash, local_resolution)) => ResolveResult::Success { + transport_version: 1, + ok: true, + plan: Box::new(plan), + semantic_hash, + local_resolution, + }, + Err(diagnostic) => ResolveResult::Failure { + transport_version: 1, + ok: false, + diagnostics: vec![diagnostic], + }, + } +} +fn resolve_inner( + bytes: &[u8], + profiles: &[String], +) -> Result<(Plan, String, LocalResolution), ResolveDiagnostic> { + let document = json::parse_with_limit(bytes, MAX_REQUEST_BYTES) + .map_err(|d| with_role(DocumentRole::Request, d))?; + if document + .value + .get("request_version") + .is_some_and(|v| v.is_u64() && v.as_u64() != Some(1)) + { + return Err(with_role( + DocumentRole::Request, + diagnostic_at( + &document.positions, + "unsupported_request_version", + "/request_version", + ), + )); + } + crate::shape::object(&document, "").map_err(|d| with_role(DocumentRole::Request, d))?; + let request: ResolveRequest = decode(&document, DocumentRole::Request)?; + let documents = [ + (DocumentRole::Project, Some(request.project.as_str())), + (DocumentRole::PrimaryLocal, request.primary_local.as_deref()), + ( + DocumentRole::CheckoutLocal, + request.checkout_local.as_deref(), + ), + ]; + let mut total = 0; + for (role, text) in documents { + if let Some(text) = text { + if text.len() > json::MAX_INPUT_BYTES { + return Err(with_role( + role, + Diagnostic::new("input_too_large", "", 1, 1), + )); + } + total += text.len(); + } + } + if total > MAX_DOCUMENT_BYTES { + return Err(with_role( + DocumentRole::Request, + Diagnostic::new("input_too_large", "", 1, 1), + )); + } + if request + .explicit_overlay + .as_ref() + .and_then(|v| v.as_ref()) + .is_some_and(|name| !overlay_name(name)) + { + return Err(with_role( + DocumentRole::Request, + diagnostic_at(&document.positions, "invalid_name", "/explicit_overlay"), + )); + } + let (plan, semantic_hash) = compile_inner(request.project.as_bytes(), profiles) + .map_err(|d| with_role(DocumentRole::Project, d))?; + // Even opted-out supplied primary input must be valid, and remains bound into the resolution generation. + let primary = request + .primary_local + .as_deref() + .map(|text| read_local(text, DocumentRole::PrimaryLocal)) + .transpose()?; + let checkout = request + .checkout_local + .as_deref() + .map(|text| read_local(text, DocumentRole::CheckoutLocal)) + .transpose()?; + let mut overlay = plan.environment.default_overlay.clone(); + let mut origin = OverlayOrigin::Project; + if plan.worktree.inherit_local + && let Some(value) = primary + .as_ref() + .and_then(|v| v.environment.default_overlay.as_ref()) + { + overlay = value.clone(); + origin = OverlayOrigin::PrimaryLocal; + } + if let Some(value) = checkout + .as_ref() + .and_then(|v| v.environment.default_overlay.as_ref()) + { + overlay = value.clone(); + origin = OverlayOrigin::CheckoutLocal; + } + if let Some(value) = &request.explicit_overlay { + overlay = value.clone(); + origin = OverlayOrigin::Explicit; + } + #[derive(Serialize)] + struct ResolutionInputs<'a> { + resolve_version: u32, + semantic_hash: &'a str, + primary_local: &'a Option, + checkout_local: &'a Option, + #[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, + explicit_overlay: &request.explicit_overlay, + }) + .map_err(|_| { + with_role( + DocumentRole::Request, + Diagnostic::new("encoding_failed", "", 1, 1), + ) + })?; + let local_resolution = LocalResolution { + overlay, + origin, + auto_branch: plan.worktree.auto_branch, + inherit_local: plan.worktree.inherit_local, + resolution_hash: format!("{:x}", Sha256::digest(encoded)), + }; + Ok((plan, semantic_hash, local_resolution)) +} + +pub fn local_schema() -> Result { + Ok(serde_json::to_string_pretty(&schemars::schema_for!(LocalConfig))? + "\n") +} diff --git a/packages/config-compiler/src/main.rs b/packages/config-compiler/src/main.rs index 291bea10a..3ae51a633 100644 --- a/packages/config-compiler/src/main.rs +++ b/packages/config-compiler/src/main.rs @@ -1,3 +1,6 @@ +use hack_config_compiler::local::{ + DocumentRole, MAX_REQUEST_BYTES, ResolveResult, local_schema, resolve, +}; use hack_config_compiler::{ CompileResult, Diagnostic, MAX_INPUT_BYTES, artifacts, compile, protocol, }; @@ -20,6 +23,34 @@ fn main() -> std::process::ExitCode { } } [command, directory] if command == "generate" => generate(Path::new(directory)), + [command, rest @ ..] if command == "resolve" => { + 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() + { + ResolveResult::failure( + DocumentRole::Request, + Diagnostic::new("input_read_failed", "", 1, 1), + ) + } else { + resolve(&bytes, &profiles) + }; + let failed = matches!(result, ResolveResult::Failure { .. }); + if emit(&result).is_err() || failed { + 1 + } else { + 0 + } + } [command, rest @ ..] if command == "compile" => { let mut profiles = Vec::new(); for pair in rest.chunks(2) { @@ -51,7 +82,7 @@ fn main() -> std::process::ExitCode { } fn usage() -> std::process::ExitCode { eprintln!( - "Usage: hack-config-compiler --protocol | compile [--profile NAME]... | generate DIR" + "Usage: hack-config-compiler --protocol | compile [--profile NAME]... | resolve [--profile NAME]... | generate DIR" ); std::process::ExitCode::from(2) } @@ -61,6 +92,7 @@ fn generate(directory: &Path) -> u8 { std::fs::create_dir_all(directory)?; std::fs::write(directory.join("hack.project.schema.json"), schema)?; std::fs::write(directory.join("native-config.ts"), dto)?; + std::fs::write(directory.join("hack.local.schema.json"), local_schema()?)?; Ok(()) })(); if result.is_err() { diff --git a/packages/config-compiler/src/model.rs b/packages/config-compiler/src/model.rs index 6badb994d..893578f05 100644 --- a/packages/config-compiler/src/model.rs +++ b/packages/config-compiler/src/model.rs @@ -35,6 +35,33 @@ pub struct Project { #[serde(default)] #[ts(as = "Option", optional)] pub environment: EnvironmentSelection, + #[serde(default)] + #[ts(as = "Option", optional)] + pub worktree: WorktreePolicy, +} + +fn enabled() -> bool { + true +} + +/// Authored worktree policy; neither flag grants runtime admission or cleanup authority. +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct WorktreePolicy { + #[serde(default = "enabled")] + #[ts(as = "Option", optional)] + pub auto_branch: bool, + #[serde(default = "enabled")] + #[ts(as = "Option", optional)] + pub inherit_local: bool, +} +impl Default for WorktreePolicy { + fn default() -> Self { + Self { + auto_branch: true, + inherit_local: true, + } + } } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -305,6 +332,7 @@ pub struct Plan { pub name: String, pub source: Source, pub environment: EnvironmentSelection, + pub worktree: WorktreePolicy, pub selected_profiles: Vec, pub storage: BTreeMap, pub services: BTreeMap, diff --git a/packages/config-compiler/src/shape.rs b/packages/config-compiler/src/shape.rs new file mode 100644 index 000000000..a6d9b876f --- /dev/null +++ b/packages/config-compiler/src/shape.rs @@ -0,0 +1,66 @@ +//! JSON object boundaries that Serde's sequence-to-struct fallback would otherwise accept. +use crate::{Diagnostic, diagnostic_at, json}; +use serde_json::Value; + +pub(crate) fn object(document: &json::Document, pointer: &str) -> Result<(), Diagnostic> { + if !document + .value + .pointer(pointer) + .is_some_and(Value::is_object) + { + return Err(diagnostic_at(&document.positions, "invalid_shape", pointer)); + } + Ok(()) +} +fn optional_object(document: &json::Document, pointer: &str) -> Result<(), Diagnostic> { + if document.value.pointer(pointer).is_some() { + object(document, pointer)?; + } + Ok(()) +} + +pub(crate) fn project(document: &json::Document) -> Result<(), Diagnostic> { + object(document, "")?; + for key in ["source", "environment", "worktree"] { + optional_object(document, &json::child("", key))?; + } + if let Some(storage) = document.value.get("storage").and_then(Value::as_object) { + for key in storage.keys() { + object(document, &json::child("/storage", key))?; + } + } + for namespace in ["services", "jobs"] { + let root = json::child("", namespace); + if let Some(workloads) = document.value.get(namespace).and_then(Value::as_object) { + for key in workloads.keys() { + let pointer = json::child(&root, key); + object(document, &pointer)?; + for field in ["build", "command", "readiness"] { + optional_object(document, &json::child(&pointer, field))?; + } + optional_object(document, &format!("{pointer}/readiness/command"))?; + if let Some(env) = document + .value + .pointer(&format!("{pointer}/environment")) + .and_then(Value::as_object) + { + for key in env.keys() { + object( + document, + &json::child(&format!("{pointer}/environment"), key), + )?; + } + } + for field in ["mounts", "depends_on"] { + let items = json::child(&pointer, field); + if let Some(values) = document.value.pointer(&items).and_then(Value::as_array) { + for index in 0..values.len() { + object(document, &json::child(&items, &index.to_string()))?; + } + } + } + } + } + } + Ok(()) +} diff --git a/packages/config-compiler/src/validate.rs b/packages/config-compiler/src/validate.rs index 82547282d..a60f4ff66 100644 --- a/packages/config-compiler/src/validate.rs +++ b/packages/config-compiler/src/validate.rs @@ -20,7 +20,7 @@ fn env_name(value: &str) -> bool { } // Canonical spelling only, equivalent to project.ts normalizeEnvConfigName(value) === value. // Managed layer selection remains exclusively owned by the environment subsystem. -fn overlay_name(value: &str) -> bool { +pub(crate) fn overlay_name(value: &str) -> bool { !value.is_empty() && value.split('-').all(|part| { !part.is_empty() @@ -199,6 +199,7 @@ pub fn lower(mut project: Project, profiles: &[String], at: &At) -> Result Value { + json!({"schema_version":1,"name":"example","environment":{"default_overlay":"project"}}) +} +fn request() -> Value { + json!({"request_version":1,"project":project().to_string()}) +} +fn local(value: Value) -> String { + json!({"schema_version":1,"environment":value}).to_string() +} +fn result(request: &Value) -> Value { + serde_json::to_value(resolve(&serde_json::to_vec(request).unwrap(), &[])).unwrap() +} +fn refusal(request: &Value, code: &str, role: &str) { + let r = result(request); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(r["diagnostics"][0]["code"], code, "{r}"); + assert_eq!(r["diagnostics"][0]["document"], role, "{r}"); +} +#[test] +fn precedence_preserves_absent_base_and_named_selections() { + let mut r = request(); + assert_eq!(result(&r)["local_resolution"]["overlay"], "project"); + assert_eq!(result(&r)["local_resolution"]["origin"], "project"); + r["primary_local"] = json!(local(json!({"default_overlay":"primary"}))); + assert_eq!(result(&r)["local_resolution"]["overlay"], "primary"); + r["checkout_local"] = json!(local(json!({}))); + assert_eq!(result(&r)["local_resolution"]["origin"], "primary_local"); + r["checkout_local"] = json!(local(json!({"default_overlay":null}))); + assert_eq!(result(&r)["local_resolution"]["overlay"], Value::Null); + assert_eq!(result(&r)["local_resolution"]["origin"], "checkout_local"); + r["checkout_local"] = json!(local(json!({"default_overlay":"checkout"}))); + assert_eq!(result(&r)["local_resolution"]["overlay"], "checkout"); + r["explicit_overlay"] = json!("explicit"); + assert_eq!(result(&r)["local_resolution"]["overlay"], "explicit"); + assert_eq!(result(&r)["local_resolution"]["origin"], "explicit"); + r["explicit_overlay"] = Value::Null; + assert_eq!(result(&r)["local_resolution"]["overlay"], Value::Null); + assert_eq!(result(&r)["local_resolution"]["origin"], "explicit"); +} +#[test] +fn default_worktree_policy_is_normalized_and_strict() { + let r = result(&request()); + assert_eq!( + r["plan"]["worktree"], + json!({"auto_branch":true,"inherit_local":true}) + ); + for bad in [Value::Null, json!("false"), json!(1)] { + let mut p = project(); + p["worktree"] = json!({"inherit_local":bad}); + let mut r = request(); + r["project"] = json!(p.to_string()); + refusal(&r, "invalid_shape", "project"); + } + let mut p = project(); + p["worktree"] = json!({"auto_branch":false,"inherit_local":false}); + let mut r = request(); + r["project"] = json!(p.to_string()); + r["primary_local"] = json!(local(json!({"default_overlay":"primary"}))); + assert_eq!(result(&r)["local_resolution"]["overlay"], "project"); + assert_eq!(result(&r)["local_resolution"]["auto_branch"], false); + r["checkout_local"] = json!(local(json!({"default_overlay":"checkout"}))); + assert_eq!(result(&r)["local_resolution"]["overlay"], "checkout"); + r["primary_local"] = json!("{broken"); + refusal(&r, "invalid_json", "primary_local"); +} +#[test] +fn all_input_versions_shapes_unknown_fields_and_duplicates_refuse() { + let mut r = request(); + r["request_version"] = json!(2); + refusal(&r, "unsupported_request_version", "request"); + r = request(); + r["projectRoot"] = json!("private-sentinel"); + refusal(&r, "unknown_field", "request"); + for role in ["primary_local", "checkout_local"] { + for value in [ + json!({"schema_version":1,"services":{}}), + json!({"schema_version":1,"worktree":{"inherit_local":false}}), + json!({"schema_version":1,"environment":{"unknown":"private-sentinel"}}), + ] { + r = request(); + r[role] = json!(value.to_string()); + refusal(&r, "unknown_field", role); + } + r = request(); + r[role] = json!({"schema_version":1}); + refusal(&r, "invalid_shape", "request"); + r = request(); + r[role] = Value::Null; + refusal(&r, "invalid_shape", "request"); + r = request(); + r[role] = json!("{\"schema_version\":2}"); + refusal(&r, "unsupported_version", role); + r = request(); + r[role] = json!( + "{\"schema_version\":1,\"environment\":{\"default_overlay\":null,\"default_overlay\":\"qa\"}}" + ); + refusal(&r, "duplicate_key", role); + } + let duplicate = br#"{"request_version":1,"request_version":1,"project":"{}"}"#; + let v = serde_json::to_value(resolve(duplicate, &[])).unwrap(); + assert_eq!(v["diagnostics"][0]["document"], "request"); + assert_eq!(v["diagnostics"][0]["code"], "duplicate_key"); + r = request(); + r["project"] = json!("{\"schema_version\":1,\"name\":\"x\",\"name\":\"y\"}"); + refusal(&r, "duplicate_key", "project"); +} +#[test] +fn canonical_local_and_explicit_names_are_validated_without_normalization() { + for name in ["qa_test", "QA", "qa.test", "qa-", ""] { + let mut r = request(); + r["explicit_overlay"] = json!(name); + refusal(&r, "invalid_name", "request"); + r = request(); + r["checkout_local"] = json!(local(json!({"default_overlay":name}))); + refusal(&r, "invalid_name", "checkout_local"); + } +} +#[test] +fn authored_hash_does_not_depend_on_local_resolution() { + let mut r = request(); + let original = result(&r); + let compiled = serde_json::to_value(compile(project().to_string().as_bytes(), &[])).unwrap(); + assert_eq!(original["plan"], compiled["plan"]); + assert_eq!(original["semantic_hash"], compiled["semantic_hash"]); + r["checkout_local"] = json!(local(json!({"default_overlay":"checkout"}))); + let changed = result(&r); + assert_eq!(changed["semantic_hash"], original["semantic_hash"]); + assert_eq!(changed["plan"], original["plan"]); + assert_ne!( + changed["local_resolution"]["resolution_hash"], + original["local_resolution"]["resolution_hash"] + ); + let mut p = project(); + p["worktree"] = json!({"auto_branch":true,"inherit_local":true}); + r = request(); + r["project"] = json!(p.to_string()); + assert_eq!(result(&r)["semantic_hash"], original["semantic_hash"]); +} +#[test] +fn resolution_hash_binds_shadowed_and_opted_out_locals_and_explicit_presence() { + let mut p = project(); + p["worktree"] = json!({"inherit_local":false}); + let mut r = request(); + r["project"] = json!(p.to_string()); + r["primary_local"] = json!(local(json!({"default_overlay":"one"}))); + r["explicit_overlay"] = Value::Null; + let first = result(&r); + r["primary_local"] = json!(local(json!({"default_overlay":"two"}))); + let second = result(&r); + assert_eq!( + first["local_resolution"]["overlay"], + second["local_resolution"]["overlay"] + ); + assert_ne!( + first["local_resolution"]["resolution_hash"], + second["local_resolution"]["resolution_hash"] + ); + r["checkout_local"] = json!(local(json!({"default_overlay":null}))); + let explicit = result(&r); + r.as_object_mut().unwrap().remove("explicit_overlay"); + let inherited = result(&r); + assert_eq!( + explicit["local_resolution"]["overlay"], + inherited["local_resolution"]["overlay"] + ); + assert_ne!( + explicit["local_resolution"]["resolution_hash"], + inherited["local_resolution"]["resolution_hash"] + ); + r["primary_local"] = + json!("{ \"environment\": {\"default_overlay\": \"two\"}, \"schema_version\": 1 }"); + assert_eq!( + result(&r)["local_resolution"]["resolution_hash"], + inherited["local_resolution"]["resolution_hash"] + ); +} +#[test] +fn request_and_embedded_input_limits_are_separate_and_redacted() { + let v = serde_json::to_value(resolve(&vec![b' '; MAX_REQUEST_BYTES + 1], &[])).unwrap(); + assert_eq!(v["diagnostics"][0]["code"], "input_too_large"); + assert_eq!(v["diagnostics"][0]["document"], "request"); + for role in ["project", "primary_local", "checkout_local"] { + let mut r = request(); + r[role] = json!(" ".repeat(MAX_INPUT_BYTES + 1)); + refusal(&r, "input_too_large", role); + } + let mut r = request(); + r["checkout_local"] = + json!("{\"schema_version\":1,\"environment\":{\"default_overlay\":\"private-sentinel\",}"); + let result = result(&r); + assert_eq!(result["diagnostics"][0]["document"], "checkout_local"); + assert!(!result.to_string().contains("private-sentinel")); +} +#[test] +fn local_schema_preserves_optional_null_and_closed_shapes() { + let schema: Value = serde_json::from_str(&local_schema().unwrap()).unwrap(); + assert_eq!(schema["additionalProperties"], false); + assert_eq!( + schema["$defs"]["LocalEnvironment"]["additionalProperties"], + false + ); + assert_eq!( + schema["$defs"]["LocalEnvironment"]["properties"]["default_overlay"]["type"], + json!(["string", "null"]) + ); + assert_eq!(local_schema().unwrap(), local_schema().unwrap()); +} +#[test] +fn resolution_profiles_preserve_the_same_authored_plan_as_compile() { + let p = json!({"schema_version":1,"name":"x","profiles":["dev"],"jobs":{"init":{"image":"image:1","profiles":["dev"]}}}); + let mut r = request(); + r["project"] = json!(p.to_string()); + let resolved = + serde_json::to_value(resolve(&serde_json::to_vec(&r).unwrap(), &["dev".into()])).unwrap(); + let compiled = + serde_json::to_value(compile(p.to_string().as_bytes(), &["dev".into()])).unwrap(); + assert_eq!(resolved["plan"], compiled["plan"]); + assert_eq!(resolved["semantic_hash"], compiled["semantic_hash"]); +} + +#[test] +fn shared_local_schema_corpus_matches_resolver_shape_acceptance() { + let cases: Vec = + serde_json::from_str(include_str!("fixtures/local-schema-corpus.json")).unwrap(); + for case in cases { + let mut r = request(); + r["checkout_local"] = json!(case["input"].to_string()); + assert_eq!(result(&r)["ok"], case["valid"], "{}", case["name"]); + } +} +#[test] +fn local_and_request_depth_limits_are_enforced() { + let mut r = request(); + r["checkout_local"] = json!(format!("{}0{}", "[".repeat(70), "]".repeat(70))); + refusal(&r, "depth_limit", "checkout_local"); + let bytes = format!("{}0{}", "[".repeat(70), "]".repeat(70)); + let value = serde_json::to_value(resolve(bytes.as_bytes(), &[])).unwrap(); + assert_eq!(value["diagnostics"][0]["document"], "request"); + assert_eq!(value["diagnostics"][0]["code"], "depth_limit"); +} diff --git a/packages/config-compiler/tests/protocol.rs b/packages/config-compiler/tests/protocol.rs index e743a1d2d..25b95e727 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}) + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1,"resolve_version":1,"local_version":1}) ); let result = run(&["compile"], br#"{"schema_version":1,"name":"example"}"#); assert!(result.status.success()); @@ -51,6 +51,8 @@ fn invalid_input_has_a_redacted_json_failure_and_exit_one() { fn unknown_flags_refuse_without_echoing_arguments() { for args in [ vec!["compile", "--profile"], + vec!["resolve", "--profile"], + vec!["resolve", "--unknown", "private-sentinel"], vec!["compile", "--unknown", "private-sentinel"], vec!["--protocol", "private-sentinel"], ] { @@ -74,3 +76,30 @@ fn profile_selection_uses_explicit_repeatable_arguments() { ); assert!(value["plan"]["jobs"]["check"].is_object()); } + +#[test] +fn resolve_binary_preserves_profiles_and_reports_document_roles() { + let project = r#"{"schema_version":1,"name":"example","profiles":["dev"],"jobs":{"init":{"image":"init:1","profiles":["dev"]}}}"#; + let request = serde_json::json!({"request_version":1,"project":project,"checkout_local":r#"{"schema_version":1,"environment":{"default_overlay":null}}"#}); + let result = run( + &["resolve", "--profile", "dev"], + request.to_string().as_bytes(), + ); + assert!(result.status.success()); + assert!(result.stderr.is_empty()); + let value: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!( + value["plan"]["selected_profiles"], + serde_json::json!(["dev"]) + ); + assert_eq!(value["local_resolution"]["origin"], "checkout_local"); + assert!(value["local_resolution"]["overlay"].is_null()); + let mut request = request; + request["checkout_local"] = serde_json::json!(r#"{"schema_version":1,"schema_version":1}"#); + let result = run(&["resolve"], request.to_string().as_bytes()); + assert_eq!(result.status.code(), Some(1)); + assert!(result.stderr.is_empty()); + let value: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(value["diagnostics"][0]["code"], "duplicate_key"); + assert_eq!(value["diagnostics"][0]["document"], "checkout_local"); +} diff --git a/scripts/build-config-compiler.ts b/scripts/build-config-compiler.ts index f41d5f9f9..e990f6f03 100644 --- a/scripts/build-config-compiler.ts +++ b/scripts/build-config-compiler.ts @@ -11,7 +11,11 @@ import { import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; -const GENERATED = ["hack.project.schema.json", "native-config.ts"] as const; +const GENERATED = [ + "hack.project.schema.json", + "hack.local.schema.json", + "native-config.ts", +] as const; /** Build the pure host compiler; verify projections before publishing local output. */ export async function buildConfigCompiler(): Promise { diff --git a/scripts/build-native-candidate.sh b/scripts/build-native-candidate.sh index 0da142232..6862020da 100755 --- a/scripts/build-native-candidate.sh +++ b/scripts/build-native-candidate.sh @@ -81,8 +81,9 @@ fi bun scripts/build-config-compiler.ts cp dist/hack-config-compiler "$out/hack-config-compiler" cp packages/config-compiler/generated/hack.project.schema.json "$out/hack.project.schema.json" +cp packages/config-compiler/generated/hack.local.schema.json "$out/hack.local.schema.json" chmod 755 "$out/hack-config-compiler" -chmod 600 "$out/hack.project.schema.json" +chmod 600 "$out/hack.project.schema.json" "$out/hack.local.schema.json" /usr/bin/codesign --force --sign - --preserve-metadata=entitlements,flags,runtime "$out/hack-config-compiler" /usr/bin/codesign --verify --strict "$out/hack-config-compiler" # Bun appends the compiled program to its runtime. Re-sign those final bytes; @@ -116,7 +117,7 @@ fi else shasum -a 256 hack-native hack-relay-guest hack-cli hack-v5 provider-pins.json README.md > SHA256SUMS fi - shasum -a 256 hack-config-compiler hack.project.schema.json >> SHA256SUMS + shasum -a 256 hack-config-compiler hack.project.schema.json hack.local.schema.json >> SHA256SUMS shasum -a 256 mcp/*/manifest.json mcp/*/hack-mcp-adapter mcp/*/hack-mcp-owner mcp/*/hack-mcp-backend >> SHA256SUMS ) echo "Candidate bundle: $out" diff --git a/scripts/check-config-compiler-cli.ts b/scripts/check-config-compiler-cli.ts index e09a3aee3..265c6cf4c 100644 --- a/scripts/check-config-compiler-cli.ts +++ b/scripts/check-config-compiler-cli.ts @@ -63,8 +63,114 @@ try { const usage = await invoke(["config", "validate", "--json"]); require(usage.exit !== 0 && (usage.stdout + usage.stderr).includes( - "--file" - ), "explicit file selection"); + "E_NATIVE_PROJECT_UNSUPPORTED" + ), "no native project refuses"); + await mkdir(join(cwd, ".hack")); + const native = join(cwd, ".hack/hack.project.json"); + const local = join(cwd, ".hack/hack.local.json"); + await Bun.write( + native, + JSON.stringify({ ...project, environment: { default_overlay: "qa" } }) + ); + const projectDefault = await resolved([], "qa", "project"); + await Bun.write( + local, + '{"schema_version":1,"environment":{"default_overlay":null}}' + ); + const localBase = await resolved([], null, "checkout_local"); + require(projectDefault.semantic_hash === localBase.semantic_hash && + projectDefault.local_resolution.resolution_hash !== + localBase.local_resolution + .resolution_hash, "separate local and authored identities"); + await resolved(["--env", "dev"], "dev", "explicit"); + await resolved(["--env", "base"], null, "explicit"); + await Bun.write( + local, + '{"schema_version":1,"environment":{"default_overlay":"qa","default_overlay":"private-credential-sentinel"}}' + ); + const duplicateLocal = await invoke(["config", "validate", "--json"]); + const localFailure: unknown = JSON.parse(duplicateLocal.stdout); + require(duplicateLocal.exit === 1 && + isRecord(localFailure) && + Array.isArray(localFailure.diagnostics) && + isRecord(localFailure.diagnostics[0]) && + localFailure.diagnostics[0].code === "duplicate_key" && + localFailure.diagnostics[0].document === "checkout_local" && + !(duplicateLocal.stdout + duplicateLocal.stderr).includes( + "private-credential-sentinel" + ), "local duplicate key and document-role redaction"); + const contextFree = await invoke([ + "config", + "validate", + "--file", + native, + "--json", + ]); + require(contextFree.exit === 0 && + !( + "local_resolution" in JSON.parse(contextFree.stdout) + ), "explicit file remains context-free with invalid local settings"); + await Bun.write( + local, + '\ufeff{"schema_version":1,"environment":{"default_overlay":null}}' + ); + const bom = await invoke(["config", "validate", "--json"]); + const bomFailure: unknown = JSON.parse(bom.stdout); + require(bom.exit === 1 && + isRecord(bomFailure) && + Array.isArray(bomFailure.diagnostics) && + isRecord(bomFailure.diagnostics[0]) && + bomFailure.diagnostics[0].code === "invalid_json" && + bomFailure.diagnostics[0].document === + "checkout_local", "BOM preserved for Rust refusal"); + await Bun.write( + local, + '{"schema_version":1,"environment":{"default_overlay":"shared"}}' + ); + await git(["init", "--quiet"], cwd); + await git(["add", ".hack/hack.project.json"], cwd); + await git( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ], + cwd + ); + const worktree = join(directory, "worktree"); + await git( + ["worktree", "add", "--quiet", "-b", "fixture-linked", worktree], + cwd + ); + await resolved([], "shared", "primary_local", worktree); + await Bun.write( + join(worktree, ".hack/hack.local.json"), + '{"schema_version":1,"environment":{"default_overlay":null}}' + ); + await resolved([], null, "checkout_local", worktree); + await resolved(["--env", "qa"], "qa", "explicit", worktree); + await Bun.write( + native, + JSON.stringify({ ...project, worktree: { inherit_local: false } }) + ); + await resolved([], "shared", "checkout_local"); + await Bun.write( + join(worktree, ".hack/hack.project.json"), + JSON.stringify({ ...project, worktree: { inherit_local: false } }) + ); + await rm(join(worktree, ".hack/hack.local.json")); + await resolved([], null, "project", worktree); + await Bun.write(join(cwd, ".hack/hack.config.json"), "{}\n"); + const mixed = await invoke(["config", "validate", "--json"]); + require(mixed.exit === 1 && + JSON.parse(mixed.stdout).error.code === + "E_NATIVE_PROJECT_CONFLICT", "mixed input refuses project-aware validation"); + await rm(join(cwd, ".hack/hack.config.json")); await rm(join(bundle, "hack-config-compiler")); const missing = await invoke([ "config", @@ -80,15 +186,17 @@ try { missingResult.error.code === "E_COMPILER_MISSING", "missing bundle refusal"); require((await readdir(home)).length === 0 && - JSON.stringify(await readdir(cwd)) === - '["project.json"]', "no registry, env, or generated runtime state"); + JSON.stringify((await readdir(join(cwd, ".hack"))).sort()) === + '["hack.local.json","hack.project.json"]' && + JSON.stringify((await readdir(join(worktree, ".hack"))).sort()) === + '["hack.project.json"]', "no registry, env, or generated runtime state"); process.stdout.write( - "Relocated compiled CLI acceptance: success, diagnostics, symbolic env, explicit file, missing sidecar, no state writes passed\n" + "Relocated compiled CLI acceptance: explicit/project validation, local tri-state, linked-worktree precedence/opt-out, separate hashes, role diagnostics, mixed/missing-sidecar refusal, symbolic env and no state writes passed\n" ); - async function invoke(args: readonly string[]) { + async function invoke(args: readonly string[], selectedCwd = cwd) { const child = Bun.spawn([join(bundle, "hack"), ...args], { - cwd, + cwd: selectedCwd, env: { PATH: "/usr/bin:/bin", HOME: home, @@ -106,6 +214,49 @@ try { ]); return { stdout, stderr, exit }; } + + async function resolved( + args: readonly string[], + overlay: string | null, + origin: string, + selectedCwd = cwd + ) { + const result = await invoke( + ["config", "validate", ...args, "--json"], + selectedCwd + ); + const value: unknown = JSON.parse(result.stdout); + require(result.exit === 0 && + isRecord(value) && + isRecord(value.local_resolution) && + typeof value.semantic_hash === "string" && + typeof value.local_resolution.resolution_hash === "string" && + value.ok === true && + value.local_resolution.overlay === overlay && + value.local_resolution.origin === + origin, "project-aware overlay precedence"); + return { + semantic_hash: value.semantic_hash, + local_resolution: { + resolution_hash: value.local_resolution.resolution_hash, + }, + }; + } + + async function git(args: readonly string[], selectedCwd: string) { + const child = Bun.spawn(["/usr/bin/git", "-C", selectedCwd, ...args], { + env: { PATH: "/usr/bin:/bin", HOME: home }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + require(exit === 0, `isolated Git fixture: ${stderr}`); + } } finally { await rm(directory, { recursive: true, force: true }); } diff --git a/scripts/check-config-schema.py b/scripts/check-config-schema.py index eb359046f..5b1c90ac6 100644 --- a/scripts/check-config-schema.py +++ b/scripts/check-config-schema.py @@ -7,27 +7,40 @@ from jsonschema import Draft202012Validator -def main(): - root = Path(__file__).resolve().parent.parent - schema = json.loads((root / "packages/config-compiler/generated/hack.project.schema.json").read_text()) - corpus = json.loads((root / "packages/config-compiler/tests/fixtures/schema-corpus.json").read_text()) +def check_corpus(root, document): + schema = json.loads((root / f"packages/config-compiler/generated/hack.{document}.schema.json").read_text()) + corpus_name = "schema-corpus.json" if document == "project" else "local-schema-corpus.json" + corpus = json.loads((root / "packages/config-compiler/tests/fixtures" / corpus_name).read_text()) Draft202012Validator.check_schema(schema) validator = Draft202012Validator(schema) compiler = root / "dist/hack-config-compiler" for case in corpus: actual = validator.is_valid(case["input"]) if actual != case["valid"]: - raise RuntimeError("Schema corpus mismatch: " + case["name"]) - result = subprocess.run([str(compiler), "compile"], - input=json.dumps(case["input"]).encode(), + raise RuntimeError(f"{document} schema corpus mismatch: " + case["name"]) + command = "compile" if document == "project" else "resolve" + request = case["input"] if document == "project" else { + "request_version": 1, + "project": '{"schema_version":1,"name":"corpus"}', + "checkout_local": json.dumps(case["input"]), + } + result = subprocess.run([str(compiler), command], + input=json.dumps(request).encode(), capture_output=True, timeout=10, check=False, env={"PATH": "/usr/bin:/bin"}) envelope = json.loads(result.stdout) if (result.returncode != (0 if case["valid"] else 1) or envelope.get("ok") != case["valid"] or envelope.get("transport_version") != 1): - raise RuntimeError("Compiler corpus mismatch: " + case["name"]) - print("Independent JSON Schema / Rust shape corpus: " + str(len(corpus)) + " cases passed") + raise RuntimeError(f"{document} compiler corpus mismatch: " + case["name"]) + return len(corpus) + + +def main(): + root = Path(__file__).resolve().parent.parent + project_count = check_corpus(root, "project") + local_count = check_corpus(root, "local") + print(f"Independent JSON Schema / Rust shape corpus: {project_count} project + {local_count} local cases passed") if __name__ == "__main__": diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py index 8efd89024..b09efabed 100644 --- a/scripts/install-prerelease.py +++ b/scripts/install-prerelease.py @@ -31,7 +31,8 @@ REVISION = re.compile(r"[0-9a-f]{40}\Z") PAYLOAD = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", "provider-pins.json", "README.md", "prerelease.json")) -COMPILER_PAYLOAD = frozenset(("hack-config-compiler", "hack.project.schema.json")) +LEGACY_COMPILER_PAYLOAD = frozenset(("hack-config-compiler", "hack.project.schema.json")) +COMPILER_PAYLOAD = LEGACY_COMPILER_PAYLOAD | {"hack.local.schema.json"} EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", "hack-config-compiler")) BUNDLE_FILES = PAYLOAD | {"SHA256SUMS"} @@ -43,9 +44,10 @@ REPOSITORY = "hack-dance/hack" DOWNLOAD_HOSTS = {"api.github.com", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"} -# Reviewed flat-layout and shared-MCP managers, before optional compiler sidecars. +# Reviewed flat-layout, shared-MCP, and project-schema compiler managers. # Keep this an explicit allowlist; a matching user-written receipt is not provenance. MANAGER_PREDECESSORS = frozenset({ + "b459ffc4f227482119b48e357c91e4607aa4f7d1f88c7ccf5b7d684f66f2c0cd", "b7c49e3fec6b06790e833db1d2dcb441d2223c283b792713be46826aa2eef877", "ca432b7fc6562bb091d17d3217f5d1daf9f91621a6919c8964ca51bee2111d0c", }) @@ -186,7 +188,8 @@ def payload_inventory(names): names = set(names) require(BUNDLE_FILES <= names, "Incomplete candidate bundle.") compiler = names & COMPILER_PAYLOAD - require(not compiler or compiler == COMPILER_PAYLOAD, "Incomplete config compiler payload.") + require(not compiler or compiler in (LEGACY_COMPILER_PAYLOAD, COMPILER_PAYLOAD), + "Incomplete config compiler payload.") extra = names - BUNDLE_FILES - COMPILER_PAYLOAD if extra: matches = [MCP_MEMBER.fullmatch(name) for name in extra] diff --git a/scripts/prerelease-plan.ts b/scripts/prerelease-plan.ts index 1fe054ab5..a1f7c1a33 100644 --- a/scripts/prerelease-plan.ts +++ b/scripts/prerelease-plan.ts @@ -16,6 +16,7 @@ export const PRERELEASE_PAYLOAD = [ export const CONFIG_COMPILER_PAYLOAD = [ "hack-config-compiler", "hack.project.schema.json", + "hack.local.schema.json", ] as const; const VERSION = /^5\.0\.0-next\.[1-9][0-9]*$/; const REVISION = /^[0-9a-f]{40}$/; @@ -388,6 +389,25 @@ async function pages({ throw new Error("GitHub pagination limit exceeded"); } +/** Older bundles may omit local schema, but never the compiler/project-schema pair. */ +function configCompilerPayload(entries: readonly string[]) { + const compilerPayload = CONFIG_COMPILER_PAYLOAD.filter((name) => + entries.includes(name) + ); + if ( + compilerPayload.length !== 0 && + !( + compilerPayload.includes("hack-config-compiler") && + compilerPayload.includes("hack.project.schema.json") + ) + ) { + throw new Error( + "Native config compiler payload requires the compiler and project schema; local schema is optional for older bundles" + ); + } + return compilerPayload; +} + export async function packagePrerelease({ plan, bundle, @@ -401,14 +421,7 @@ export async function packagePrerelease({ throw new Error("Native bundle root must be a directory, not an alias"); } const entries = await readdir(bundle); - const compilerPayload = CONFIG_COMPILER_PAYLOAD.filter((name) => - entries.includes(name) - ); - if (compilerPayload.length !== 0 && compilerPayload.length !== 2) { - throw new Error( - "Native config compiler payload must include both compiler and schema" - ); - } + const compilerPayload = configCompilerPayload(entries); const mcpPayload = await nativeCandidateMcpPayload(bundle); const payload = [...PRERELEASE_PAYLOAD, ...compilerPayload, ...mcpPayload]; const expected = [ @@ -429,7 +442,8 @@ export async function packagePrerelease({ } if ( name === "hack-config-compiler" || - name === "hack.project.schema.json" + name === "hack.project.schema.json" || + name === "hack.local.schema.json" ) { const mode = name === "hack-config-compiler" ? 0o755 : 0o600; if ((entry.mode & 0o7777) !== mode) { diff --git a/src/commands/config-validate.ts b/src/commands/config-validate.ts index 337fc59e8..6d15742c9 100644 --- a/src/commands/config-validate.ts +++ b/src/commands/config-validate.ts @@ -5,27 +5,32 @@ import { defineOption, withHandler, } from "../cli/command.ts"; -import { optJson } from "../cli/options.ts"; +import { optEnv, optJson, optPath } from "../cli/options.ts"; +import { HackCliError } from "../lib/cli-result.ts"; import { compileNativeConfig, + type NativeConfigCompileResult, NativeConfigCompilerError, + type NativeConfigResolveResult, readNativeConfigInput, } from "../lib/native-config-compiler.ts"; +import { validateNativeProject } from "../lib/native-project-validation.ts"; const spec = defineCommand({ name: "validate", summary: - "Validate an explicit native project file without starting workloads", + "Validate native configuration and selected local overlays without starting workloads", group: "Project", description: - "Uses the matching bundled Rust compiler. This experimental command does not discover a project, resolve secrets, or adopt native configuration for runtime commands.", + "Uses the matching bundled Rust compiler. Without --file, discovers a native project and resolves permitted worktree-local overlay settings. --file validates only the explicit document. Neither mode reads env values, writes state, or starts workloads.", options: [ defineOption({ name: "file", type: "string", long: "--file", valueHint: "", - description: "Required native project JSON file", + description: + "Validate only this native project JSON file, without discovery or local overrides", } as const), defineOption({ name: "profile", @@ -34,6 +39,8 @@ const spec = defineCommand({ valueHint: "", description: "Comma-separated declared native profiles", } as const), + optPath, + optEnv, optJson, ], positionals: [], @@ -44,8 +51,16 @@ export const configValidateCommand = withHandler( spec, async ({ ctx, args }) => { const file = args.options.file; - if (!file?.trim()) { - throw new CliUsageError("Native validation requires --file ."); + if (file !== undefined && !file.trim()) { + throw new CliUsageError("Native file paths must not be empty."); + } + if ( + file !== undefined && + (args.options.path !== undefined || args.options.env !== undefined) + ) { + throw new CliUsageError( + "--file validates one document and cannot be combined with --path or --env." + ); } const profiles = args.options.profile ?.split(",") @@ -58,31 +73,28 @@ export const configValidateCommand = withHandler( process.once("SIGINT", cancel); process.once("SIGTERM", cancel); try { - const input = await readNativeConfigInput({ - path: resolve(ctx.cwd, file), - }); - const result = await compileNativeConfig({ - input, - profiles, - signal: controller.signal, - }); - if (args.options.json) { - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - } else if (result.ok) { - process.stdout.write( - `Native configuration is valid. Semantic hash: ${result.semantic_hash}\n` - ); - } else { - for (const diagnostic of result.diagnostics) { - process.stderr.write( - `${diagnostic.code} ${JSON.stringify(diagnostic.pointer || "/")} (${diagnostic.line}:${diagnostic.column}): ${diagnostic.message}\n` - ); - } - } + const result = + file === undefined + ? await validateNativeProject({ + startDir: resolve(ctx.cwd, args.options.path ?? "."), + profiles, + explicitOverlay: + args.options.env === "base" ? null : args.options.env, + signal: controller.signal, + }) + : await compileNativeConfig({ + input: await readNativeConfigInput({ + path: resolve(ctx.cwd, file), + }), + profiles, + signal: controller.signal, + }); + renderValidationResult({ result, json: args.options.json === true }); return result.ok ? 0 : 1; } catch (error: unknown) { const failure = - error instanceof NativeConfigCompilerError + error instanceof NativeConfigCompilerError || + error instanceof HackCliError ? error : new NativeConfigCompilerError( "E_COMPILER_REQUEST", @@ -102,3 +114,29 @@ export const configValidateCommand = withHandler( } } ); + +function renderValidationResult(opts: { + readonly result: NativeConfigCompileResult | NativeConfigResolveResult; + readonly json: boolean; +}): void { + const result = opts.result; + if (opts.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else if (result.ok) { + process.stdout.write( + `Native configuration is valid. Semantic hash: ${result.semantic_hash}\n` + ); + if ("local_resolution" in result) { + const local = result.local_resolution; + process.stdout.write( + `Selected env: ${local.overlay ?? "base"} (${local.origin}). Local resolution hash: ${local.resolution_hash}\n` + ); + } + } else { + for (const diagnostic of result.diagnostics) { + process.stderr.write( + `${diagnostic.document ? `${diagnostic.document} ` : ""}${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 378db23e1..709968170 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -41,8 +41,7 @@ type ConfigReadResult = const configSpec = defineCommand({ name: "config", - summary: - "Read/write legacy config or validate an explicit native project file", + summary: "Read/write legacy config or validate native project configuration", group: "Project", options: [], positionals: [], diff --git a/src/lib/native-config-compiler.ts b/src/lib/native-config-compiler.ts index 6ee416954..bf85ed08e 100644 --- a/src/lib/native-config-compiler.ts +++ b/src/lib/native-config-compiler.ts @@ -7,6 +7,8 @@ const OUTPUT_LIMIT = 8 * 1024 * 1024; const STDERR_LIMIT = 64 * 1024; const DEFAULT_TIMEOUT_MS = 10_000; const HASH_PATTERN = /^[a-f0-9]{64}$/; +const OVERLAY_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const RESOLVE_REQUEST_LIMIT = 20 * 1024 * 1024; export type NativeConfigDiagnostic = { readonly code: string; @@ -14,8 +16,29 @@ export type NativeConfigDiagnostic = { readonly message: string; readonly line: number; readonly column: number; + readonly document?: NativeConfigDocumentRole; }; +export type NativeConfigDocumentRole = + | "project" + | "primary_local" + | "checkout_local" + | "request"; + +export type NativeLocalResolution = { + readonly overlay: string | null; + readonly origin: "project" | "primary_local" | "checkout_local" | "explicit"; + readonly auto_branch: boolean; + readonly inherit_local: boolean; + readonly resolution_hash: string; +}; + +export type NativeConfigResolveResult = + | (Extract & { + readonly local_resolution: NativeLocalResolution; + }) + | Extract; + export type NativeConfigCompileResult = | { readonly transport_version: 1; @@ -98,6 +121,7 @@ export async function compileNativeConfig(opts: { readonly profiles?: readonly string[]; readonly timeoutMs?: number; readonly signal?: AbortSignal; + readonly requireLocalResolution?: boolean; }): Promise { if (opts.input.byteLength > NATIVE_CONFIG_INPUT_LIMIT) { throw failure( @@ -105,6 +129,122 @@ export async function compileNativeConfig(opts: { "Native configuration exceeds the input budget." ); } + const request = compilerRequest(opts); + await checkProtocol({ + ...request, + requireLocalResolution: opts.requireLocalResolution, + }); + const response = await invokeCompiler({ + ...request, + args: compileArguments("compile", opts.profiles), + input: opts.input, + }); + return parseCompileResponse(response); +} + +/** Resolve original document text in Rust; this transport never parses local policy. */ +export async function resolveNativeConfig(opts: { + readonly input: Uint8Array; + readonly primaryLocal?: Uint8Array; + readonly checkoutLocal?: Uint8Array; + readonly explicitOverlay?: string | null; + readonly binary?: string; + readonly profiles?: readonly string[]; + readonly timeoutMs?: number; + readonly signal?: AbortSignal; +}): Promise { + if ( + typeof opts.explicitOverlay === "string" && + Buffer.byteLength(opts.explicitOverlay, "utf8") > NATIVE_CONFIG_INPUT_LIMIT + ) { + throw failure( + "E_CONFIG_INPUT", + "Native explicit overlay selection exceeds the input budget." + ); + } + const input = new TextEncoder().encode( + JSON.stringify({ + request_version: 1, + project: documentText(opts.input, "project"), + primary_local: + opts.primaryLocal === undefined + ? undefined + : documentText(opts.primaryLocal, "primary_local"), + checkout_local: + opts.checkoutLocal === undefined + ? undefined + : documentText(opts.checkoutLocal, "checkout_local"), + explicit_overlay: opts.explicitOverlay, + }) + ); + if (input.byteLength > RESOLVE_REQUEST_LIMIT) { + throw failure( + "E_CONFIG_INPUT", + "Native local resolution request exceeds the input budget." + ); + } + const request = compilerRequest(opts); + await checkProtocol({ ...request, requireLocalResolution: true }); + const response = await invokeCompiler({ + ...request, + args: compileArguments("resolve", opts.profiles), + input, + }); + const envelope = parseControlJson(response.output); + const result = parseCompileValue({ + value: envelope, + exitCode: response.exitCode, + }); + if (!result.ok) { + if ( + result.diagnostics.some((diagnostic) => diagnostic.document === undefined) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native local resolution returned an invalid diagnostic." + ); + } + return result; + } + if (!isRecord(envelope)) { + throw failure( + "E_COMPILER_RESPONSE", + "Native local resolution returned an invalid envelope." + ); + } + return { + ...result, + local_resolution: parseLocalResolution(envelope.local_resolution), + }; +} + +function documentText( + input: Uint8Array, + role: NativeConfigDocumentRole +): string { + if (input.byteLength > NATIVE_CONFIG_INPUT_LIMIT) { + throw failure( + "E_CONFIG_INPUT", + "Native configuration exceeds the input budget." + ); + } + try { + return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( + input + ); + } catch { + throw failure( + "E_CONFIG_INPUT", + `Native ${role} input must be valid UTF-8.` + ); + } +} + +function compilerRequest(opts: { + readonly binary?: string; + readonly timeoutMs?: number; + readonly signal?: AbortSignal; +}) { const binary = opts.binary ?? resolveNativeConfigCompilerBinary(); if (!isAbsolute(binary)) { throw failure("E_COMPILER_PATH", "Compiler path must be absolute."); @@ -116,31 +256,81 @@ export async function compileNativeConfig(opts: { "Compiler timeout is outside the supported budget." ); } - const request = { binary, timeoutMs, signal: opts.signal }; - const handshake = await invokeCompiler({ ...request, args: ["--protocol"] }); + return { binary, timeoutMs, signal: opts.signal }; +} + +async function checkProtocol(opts: { + readonly binary: string; + readonly timeoutMs: number; + readonly signal?: AbortSignal; + readonly requireLocalResolution?: boolean; +}): Promise { + const handshake = await invokeCompiler({ ...opts, args: ["--protocol"] }); const protocol = parseControlJson(handshake.output); if ( handshake.exitCode !== 0 || !isRecord(protocol) || protocol.transport_version !== 1 || protocol.authored_version !== 1 || - protocol.plan_version !== 1 + protocol.plan_version !== 1 || + (opts.requireLocalResolution && + (protocol.resolve_version !== 1 || protocol.local_version !== 1)) ) { throw failure( "E_COMPILER_VERSION", "Native configuration compiler version mismatch." ); } - const args = ["compile"]; - for (const profile of opts.profiles ?? []) { +} + +function compileArguments( + command: "compile" | "resolve", + profiles?: readonly string[] +): string[] { + const args: string[] = [command]; + for (const profile of profiles ?? []) { args.push("--profile", profile); } - const response = await invokeCompiler({ - ...request, - args, - input: opts.input, - }); - return parseCompileResponse(response); + return args; +} + +function isResolutionOrigin( + value: unknown +): value is NativeLocalResolution["origin"] { + return ( + value === "project" || + value === "primary_local" || + value === "checkout_local" || + value === "explicit" + ); +} + +function parseLocalResolution(value: unknown): NativeLocalResolution { + if ( + !( + isRecord(value) && + (value.overlay === null || + (typeof value.overlay === "string" && + OVERLAY_PATTERN.test(value.overlay))) && + isResolutionOrigin(value.origin) + ) || + typeof value.auto_branch !== "boolean" || + typeof value.inherit_local !== "boolean" || + typeof value.resolution_hash !== "string" || + !HASH_PATTERN.test(value.resolution_hash) + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native local resolution returned an invalid result." + ); + } + return { + overlay: value.overlay, + origin: value.origin, + auto_branch: value.auto_branch, + inherit_local: value.inherit_local, + resolution_hash: value.resolution_hash, + }; } async function invokeCompiler(opts: { @@ -267,7 +457,17 @@ function parseCompileResponse(opts: { readonly output: Uint8Array; readonly exitCode: number; }): NativeConfigCompileResult { - const value = parseControlJson(opts.output); + return parseCompileValue({ + value: parseControlJson(opts.output), + exitCode: opts.exitCode, + }); +} + +function parseCompileValue(opts: { + readonly value: unknown; + readonly exitCode: number; +}): NativeConfigCompileResult { + const value = opts.value; if (!isRecord(value) || value.transport_version !== 1) { throw failure( "E_COMPILER_RESPONSE", @@ -316,7 +516,8 @@ function parseDiagnostic(value: unknown): NativeConfigDiagnostic { value.line < 1 || typeof value.column !== "number" || !Number.isSafeInteger(value.column) || - value.column < 1 + value.column < 1 || + (value.document !== undefined && !isDocumentRole(value.document)) ) { throw failure( "E_COMPILER_RESPONSE", @@ -329,9 +530,19 @@ function parseDiagnostic(value: unknown): NativeConfigDiagnostic { message: value.message, line: value.line, column: value.column, + ...(isDocumentRole(value.document) ? { document: value.document } : {}), }; } +function isDocumentRole(value: unknown): value is NativeConfigDocumentRole { + return ( + value === "project" || + value === "primary_local" || + value === "checkout_local" || + value === "request" + ); +} + function failure(code: string, message: string): NativeConfigCompilerError { return new NativeConfigCompilerError(code, message); } diff --git a/src/lib/native-project-inputs.ts b/src/lib/native-project-inputs.ts new file mode 100644 index 000000000..1a5166891 --- /dev/null +++ b/src/lib/native-project-inputs.ts @@ -0,0 +1,296 @@ +import { constants, type Stats } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import { resolve } from "node:path"; +import { HACK_PROJECT_DIR_PRIMARY } from "../constants.ts"; +import { HackCliError } from "./cli-result.ts"; +import { isRecord } from "./guards.ts"; +import { + NATIVE_CONFIG_INPUT_LIMIT, + NativeConfigCompilerError, +} from "./native-config-compiler.ts"; +import { + discoverProjectInputs, + inspectProjectInputsAtRoot, + NATIVE_PROJECT_FILENAME, + ProjectInputSelectionError, +} from "./project-input-selection.ts"; +import { + resolveVerifiedPrimaryWorktreeRoot, + shouldInheritPrimaryLocalInputs, +} from "./worktree-local-config.ts"; + +const NATIVE_LOCAL_FILENAME = "hack.local.json"; + +/** Fixed failures never disclose authored bytes, paths, or filesystem error text. */ +export class NativeProjectInputError extends HackCliError { + constructor(kind: "unsupported" | "unsafe") { + super({ + code: + kind === "unsupported" + ? "E_NATIVE_PROJECT_UNSUPPORTED" + : "E_CONFIG_INVALID", + message: + kind === "unsupported" + ? "Project-aware native validation requires an exact native input family; values omitted." + : "Native project inputs must be stable, readable regular files of at most 1 MiB in unredirected directories; values omitted.", + }); + this.name = "NativeProjectInputError"; + } +} + +/** Select native input only, preserving the shared discovery boundary and raw bytes. */ +export async function acquireNativeProjectInput(opts: { + readonly startDir: string; + readonly signal?: AbortSignal; +}): Promise<{ readonly projectRoot: string; readonly input: Uint8Array }> { + try { + throwIfCancelled(opts.signal); + const selected = await discoverProjectInputs(opts); + if (selected?.kind === "conflict") { + throw new ProjectInputSelectionError("conflict"); + } + if (selected?.kind !== "native") { + throw new NativeProjectInputError("unsupported"); + } + const input = await readAuthoredNativeFile({ + projectRoot: selected.projectRoot, + filename: NATIVE_PROJECT_FILENAME, + required: true, + signal: opts.signal, + }); + await requireNativeFamilyAtRoot({ projectRoot: selected.projectRoot }); + throwIfCancelled(opts.signal); + if (!input) { + throw new NativeProjectInputError("unsafe"); + } + return { projectRoot: selected.projectRoot, input }; + } catch (error: unknown) { + throw redactAcquisitionError(error); + } +} + +/** + * Acquire only the optional raw local documents. `inheritLocal` comes from the + * validated Rust project policy; no policy, environment, or generated state is + * interpreted here. Descriptor checks are preflight, not a multi-file snapshot. + */ +export async function acquireNativeLocalInputs(opts: { + readonly projectRoot: string; + readonly inheritLocal: boolean; + readonly signal?: AbortSignal; +}): Promise<{ + readonly primaryLocal?: Uint8Array; + readonly checkoutLocal?: Uint8Array; +}> { + try { + throwIfCancelled(opts.signal); + const projectRoot = resolve(opts.projectRoot); + await requireNativeFamilyAtRoot({ projectRoot }); + await readAuthoredNativeFile({ + projectRoot, + filename: NATIVE_PROJECT_FILENAME, + required: true, + signal: opts.signal, + }); + let primaryLocal: Uint8Array | undefined; + if (shouldInheritPrimaryLocalInputs(opts)) { + const primaryRoot = await resolveVerifiedPrimaryWorktreeRoot({ + projectRoot, + signal: opts.signal, + }); + if (primaryRoot) { + await requireNativeFamilyAtRoot({ projectRoot: primaryRoot }); + await readAuthoredNativeFile({ + projectRoot: primaryRoot, + filename: NATIVE_PROJECT_FILENAME, + required: true, + signal: opts.signal, + }); + primaryLocal = await readAuthoredNativeFile({ + projectRoot: primaryRoot, + filename: NATIVE_LOCAL_FILENAME, + required: false, + signal: opts.signal, + }); + await requireNativeFamilyAtRoot({ projectRoot: primaryRoot }); + } + } + const checkoutLocal = await readAuthoredNativeFile({ + projectRoot, + filename: NATIVE_LOCAL_FILENAME, + required: false, + signal: opts.signal, + }); + await requireNativeFamilyAtRoot({ projectRoot }); + throwIfCancelled(opts.signal); + return { + ...(primaryLocal === undefined ? {} : { primaryLocal }), + ...(checkoutLocal === undefined ? {} : { checkoutLocal }), + }; + } catch (error: unknown) { + throw redactAcquisitionError(error); + } +} + +async function requireNativeFamilyAtRoot(opts: { + readonly projectRoot: string; +}): Promise { + const selected = await inspectProjectInputsAtRoot(opts); + if (selected.kind === "conflict") { + throw new ProjectInputSelectionError("conflict"); + } + if (selected.kind !== "native") { + throw new NativeProjectInputError("unsupported"); + } +} + +type DirectoryIdentity = { + readonly path: string; + readonly stats: Stats; +}; + +async function inspectInputDirectories( + projectRoot: string +): Promise { + const directories: DirectoryIdentity[] = []; + for (const path of [ + projectRoot, + resolve(projectRoot, HACK_PROJECT_DIR_PRIMARY), + ]) { + const stats = await lstat(path); + if (!stats.isDirectory() || (await realpath(path)) !== path) { + throw new NativeProjectInputError("unsafe"); + } + directories.push({ path, stats }); + } + return directories; +} + +async function recheckInputDirectories( + directories: readonly DirectoryIdentity[] +): Promise { + for (const directory of directories) { + const current = await lstat(directory.path); + if ( + !(current.isDirectory() && sameIdentity(directory.stats, current)) || + (await realpath(directory.path)) !== directory.path + ) { + throw new NativeProjectInputError("unsafe"); + } + } +} + +/** Bound descriptor reads, reject path redirection, and reject identity/content changes. */ +async function readAuthoredNativeFile(opts: { + readonly projectRoot: string; + readonly filename: string; + readonly required: boolean; + readonly signal?: AbortSignal; +}): Promise { + throwIfCancelled(opts.signal); + const directories = await inspectInputDirectories(opts.projectRoot); + const path = resolve( + opts.projectRoot, + HACK_PROJECT_DIR_PRIMARY, + opts.filename + ); + const observed = await lstat(path).catch((error: unknown) => { + if (!opts.required && isRecord(error) && error.code === "ENOENT") { + return null; + } + throw new NativeProjectInputError("unsafe"); + }); + if (!observed) { + await recheckInputDirectories(directories); + return undefined; + } + if ( + !observed.isFile() || + observed.size > NATIVE_CONFIG_INPUT_LIMIT || + (observed.mode & 0o444) === 0 || + (await realpath(path)) !== path + ) { + throw new NativeProjectInputError("unsafe"); + } + const file = await open( + path, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + try { + throwIfCancelled(opts.signal); + const before = await file.stat(); + if ( + !( + before.isFile() && + sameIdentity(observed, before) && + sameContentMetadata(observed, before) + ) + ) { + throw new NativeProjectInputError("unsafe"); + } + const buffer = Buffer.alloc(before.size + 1); + let size = 0; + while (size < buffer.length) { + throwIfCancelled(opts.signal); + const { bytesRead } = await file.read( + buffer, + size, + buffer.length - size, + size + ); + if (bytesRead === 0) { + break; + } + size += bytesRead; + } + const after = await file.stat(); + const current = await lstat(path); + if ( + size > NATIVE_CONFIG_INPUT_LIMIT || + size !== before.size || + !current.isFile() || + !sameIdentity(before, after) || + !sameIdentity(before, current) || + !sameContentMetadata(before, after) || + !sameContentMetadata(before, current) || + (await realpath(path)) !== path + ) { + throw new NativeProjectInputError("unsafe"); + } + await recheckInputDirectories(directories); + throwIfCancelled(opts.signal); + return Uint8Array.from(buffer.subarray(0, size)); + } finally { + await file.close(); + } +} + +function sameIdentity(before: Stats, after: Stats): boolean { + return before.dev === after.dev && before.ino === after.ino; +} + +function sameContentMetadata(before: Stats, after: Stats): boolean { + return ( + before.size === after.size && + before.mtimeMs === after.mtimeMs && + before.ctimeMs === after.ctimeMs + ); +} + +function redactAcquisitionError( + error: unknown +): HackCliError | NativeConfigCompilerError { + return error instanceof HackCliError || + error instanceof NativeConfigCompilerError + ? error + : new NativeProjectInputError("unsafe"); +} + +function throwIfCancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new NativeConfigCompilerError( + "E_COMPILER_CANCELLED", + "Native configuration input acquisition was cancelled." + ); + } +} diff --git a/src/lib/native-project-validation.ts b/src/lib/native-project-validation.ts new file mode 100644 index 000000000..e25e4cbc1 --- /dev/null +++ b/src/lib/native-project-validation.ts @@ -0,0 +1,58 @@ +import { isRecord } from "./guards.ts"; +import { + compileNativeConfig, + NativeConfigCompilerError, + type NativeConfigResolveResult, + resolveNativeConfig, +} from "./native-config-compiler.ts"; +import { + acquireNativeLocalInputs, + acquireNativeProjectInput, +} from "./native-project-inputs.ts"; + +/** Offline project selection; local policy is read only after Rust validates it. */ +export async function validateNativeProject(opts: { + readonly startDir: string; + readonly profiles?: readonly string[]; + readonly explicitOverlay?: string | null; + readonly signal?: AbortSignal; +}): Promise { + const project = await acquireNativeProjectInput({ + startDir: opts.startDir, + signal: opts.signal, + }); + const compiled = await compileNativeConfig({ + input: project.input, + profiles: opts.profiles, + signal: opts.signal, + requireLocalResolution: true, + }); + if (!compiled.ok) { + return { + ...compiled, + diagnostics: compiled.diagnostics.map((diagnostic) => ({ + ...diagnostic, + document: "project" as const, + })), + }; + } + const worktree = compiled.plan.worktree; + if (!isRecord(worktree) || typeof worktree.inherit_local !== "boolean") { + throw new NativeConfigCompilerError( + "E_COMPILER_RESPONSE", + "Native configuration compiler returned invalid worktree policy." + ); + } + const locals = await acquireNativeLocalInputs({ + projectRoot: project.projectRoot, + inheritLocal: worktree.inherit_local, + signal: opts.signal, + }); + return await resolveNativeConfig({ + input: project.input, + ...locals, + profiles: opts.profiles, + explicitOverlay: opts.explicitOverlay, + signal: opts.signal, + }); +} diff --git a/src/lib/worktree-local-config.ts b/src/lib/worktree-local-config.ts index bab854548..ee66216ad 100644 --- a/src/lib/worktree-local-config.ts +++ b/src/lib/worktree-local-config.ts @@ -1,18 +1,289 @@ -import { realpath } from "node:fs/promises"; -import { basename, relative, resolve } from "node:path"; +import { lstat, realpath } from "node:fs/promises"; +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; +import { HackCliError } from "./cli-result.ts"; import { isSlimExecutionMode } from "./execution-mode.ts"; import { isLinkedGitWorktree, resolveGitPrimaryWorktreeRoot, } from "./git-worktree.ts"; +import { isRecord } from "./guards.ts"; +import { NativeConfigCompilerError } from "./native-config-compiler.ts"; import { readProjectConfig } from "./project.ts"; +/** Keep runner exclusions independent of either configuration format's policy parser. */ +export function shouldInheritPrimaryLocalInputs(opts: { + readonly inheritLocal: boolean; +}): boolean { + return ( + opts.inheritLocal && + !isSlimExecutionMode() && + !["1", "true"].includes(process.env.CI ?? "") + ); +} + +/** + * Verify both real Git roots and their shared administrative identity before + * admitting a primary checkout. Without an exact-root .git marker there is no primary; + * present but unverifiable Git linkage fails closed. This does not parse policy. + */ +export async function resolveVerifiedPrimaryWorktreeRoot(opts: { + readonly projectRoot: string; + readonly signal?: AbortSignal; +}): Promise { + try { + throwIfCancelled(opts.signal); + const checkoutRoot = await realpath(opts.projectRoot); + const checkout = await readGitCheckoutIdentity({ + projectRoot: checkoutRoot, + signal: opts.signal, + }); + if (!checkout || checkout.gitDir === checkout.commonDir) { + return null; + } + if ( + basename(checkout.commonDir) !== ".git" || + dirname(dirname(checkout.gitDir)) !== checkout.commonDir || + basename(dirname(checkout.gitDir)) !== "worktrees" + ) { + throw worktreeVerificationError(); + } + const primaryRoot = dirname(checkout.commonDir); + const primary = await readGitCheckoutIdentity({ + projectRoot: primaryRoot, + signal: opts.signal, + }); + if ( + !primary || + primary.commonDir !== checkout.commonDir || + primary.gitDir !== checkout.commonDir + ) { + throw worktreeVerificationError(); + } + const listing = await readGitInspection({ + projectRoot: checkoutRoot, + args: ["worktree", "list", "--porcelain", "-z"], + signal: opts.signal, + }); + const listedRoots = listing + ?.split("\0") + .filter((field) => field.startsWith("worktree ")) + .map((field) => field.slice("worktree ".length)); + if ( + !( + listedRoots?.includes(checkoutRoot) && listedRoots.includes(primaryRoot) + ) + ) { + throw worktreeVerificationError(); + } + return primaryRoot; + } catch (error: unknown) { + if (error instanceof NativeConfigCompilerError) { + throw error; + } + throw worktreeVerificationError(); + } +} + +type GitCheckoutIdentity = { + readonly gitDir: string; + readonly commonDir: string; +}; + +async function readGitCheckoutIdentity(opts: { + readonly projectRoot: string; + readonly signal?: AbortSignal; +}): Promise { + throwIfCancelled(opts.signal); + const gitMarker = await lstat(resolve(opts.projectRoot, ".git")).catch( + (error: unknown) => { + if (isRecord(error) && error.code === "ENOENT") { + return null; + } + throw worktreeVerificationError(); + } + ); + if (!gitMarker) { + return null; + } + if (!(gitMarker.isFile() || gitMarker.isDirectory())) { + throw worktreeVerificationError(); + } + const result = await readGitInspection({ + projectRoot: opts.projectRoot, + args: [ + "rev-parse", + "--show-toplevel", + "--absolute-git-dir", + "--path-format=absolute", + "--git-common-dir", + ], + signal: opts.signal, + }); + if (result === null) { + throw worktreeVerificationError(); + } + const [root, gitDir, commonDir, extra] = result.trimEnd().split("\n"); + if ( + !(root && gitDir && commonDir) || + extra !== undefined || + ![root, gitDir, commonDir].every(isAbsolute) + ) { + throw worktreeVerificationError(); + } + const realRoot = await realpath(root); + if (realRoot !== opts.projectRoot) { + throw worktreeVerificationError(); + } + const after = await lstat(resolve(opts.projectRoot, ".git")); + if ( + gitMarker.dev !== after.dev || + gitMarker.ino !== after.ino || + gitMarker.ctimeMs !== after.ctimeMs + ) { + throw worktreeVerificationError(); + } + return { + gitDir: await realpath(gitDir), + commonDir: await realpath(commonDir), + }; +} + +/** Ignore caller Git redirection and never surface repository paths or child output. */ +async function readGitInspection(opts: { + readonly projectRoot: string; + readonly args: readonly string[]; + readonly signal?: AbortSignal; +}): Promise { + throwIfCancelled(opts.signal); + const env: Record = { + GIT_OPTIONAL_LOCKS: "0", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + }; + for (const name of [ + "PATH", + "HOME", + "SYSTEMROOT", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LC_ALL", + "LC_CTYPE", + ]) { + const value = process.env[name]; + if (value !== undefined) { + env[name] = value; + } + } + try { + const child = Bun.spawn(["git", "-C", opts.projectRoot, ...opts.args], { + env, + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + detached: true, + }); + const kill = () => { + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + child.kill("SIGKILL"); + } + }; + let timedOut = false; + let cancelled = false; + const cancel = () => { + cancelled = true; + kill(); + }; + const timer = setTimeout(() => { + timedOut = true; + kill(); + }, 10_000); + opts.signal?.addEventListener("abort", cancel, { once: true }); + if (opts.signal?.aborted) { + cancel(); + } + try { + const [output, code] = await Promise.all([ + readGitOutput(child.stdout), + child.exited, + ]); + if (cancelled) { + throwIfCancelled(opts.signal); + } + return code === 0 && !timedOut ? output : null; + } finally { + clearTimeout(timer); + opts.signal?.removeEventListener("abort", cancel); + if (child.exitCode === null) { + kill(); + } + await child.exited; + } + } catch (error: unknown) { + throwIfCancelled(opts.signal); + if (error instanceof NativeConfigCompilerError) { + throw error; + } + return null; + } +} + +function throwIfCancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new NativeConfigCompilerError( + "E_COMPILER_CANCELLED", + "Native configuration input acquisition was cancelled." + ); + } +} + +async function readGitOutput( + stream: ReadableStream +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) { + break; + } + size += next.value.byteLength; + if (size > 1024 * 1024) { + throw worktreeVerificationError(); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); +} + +function worktreeVerificationError(): HackCliError { + return new HackCliError({ + code: "E_CONFIG_INVALID", + message: + "Cannot verify the local configuration Git worktree family; values omitted.", + }); +} + /** Resolve only the matching primary configuration directory; never copy state. */ export async function resolvePrimaryLocalProjectDir(opts: { readonly projectRoot: string; readonly projectDir: string; }): Promise { - if (isSlimExecutionMode() || ["1", "true"].includes(process.env.CI ?? "")) { + if (!shouldInheritPrimaryLocalInputs({ inheritLocal: true })) { return null; } const name = basename(opts.projectDir); diff --git a/tests/native-config-command.test.ts b/tests/native-config-command.test.ts index 37577f0a2..7e6d74961 100644 --- a/tests/native-config-command.test.ts +++ b/tests/native-config-command.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; @@ -12,13 +12,25 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }); }); -async function startCli(opts: { compilerBody: string; json?: boolean }) { +async function startCli(opts: { + compilerBody: string; + json?: boolean; + project?: boolean; + args?: readonly string[]; +}) { const compiler = join(directory, "compiler"); const file = join(directory, "input.json"); await Bun.write(file, '{"schema_version":1,"name":"example"}'); + if (opts.project) { + await mkdir(join(directory, ".hack")); + await Bun.write( + join(directory, ".hack/hack.project.json"), + '{"schema_version":1,"name":"example"}' + ); + } await Bun.write( compiler, - `#!${process.execPath}\nif (process.argv[2] === '--protocol') { console.log('{"transport_version":1,"authored_version":1,"plan_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}'); } else { ${opts.compilerBody} }` ); await chmod(compiler, 0o755); return Bun.spawn( @@ -27,8 +39,8 @@ async function startCli(opts: { compilerBody: string; json?: boolean }) { join(root, "index.ts"), "config", "validate", - "--file", - file, + ...(opts.project ? [] : ["--file", file]), + ...(opts.args ?? []), ...(opts.json ? ["--json"] : []), ], { @@ -46,6 +58,38 @@ async function startCli(opts: { compilerBody: string; json?: boolean }) { ); } +test("project diagnostics preserve the document role and escaped pointer", async () => { + const child = await startCli({ + project: true, + compilerBody: `console.log(JSON.stringify({transport_version:1,ok:false,diagnostics:[{code:"duplicate_key",pointer:"/name\\u001b[2J",message:"Duplicate JSON object keys are not allowed.",line:2,column:3}]})); process.exitCode=1;`, + }); + 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).toBe(""); + expect(stderr).toContain('project duplicate_key "/name\\u001b[2J" (2:3)'); + expect(stderr).not.toContain("\u001b[2J"); +}); + +test.each([ + ["--env", "base"], + ["--path", "."], +])("explicit-file mode refuses project-selection flags %s", async (flag, value) => { + const child = await startCli({ + compilerBody: "throw new Error('compiler must not run');", + args: [flag, value], + }); + const [stderr, exit] = await Promise.all([ + new Response(child.stderr).text(), + child.exited, + ]); + expect(exit).not.toBe(0); + expect(stderr).toContain("cannot be combined with --path or --env"); +}); + test("human diagnostics escape authored control characters", async () => { const response = { transport_version: 1, diff --git a/tests/native-config-compiler.test.ts b/tests/native-config-compiler.test.ts index 8a2ed23a2..3c1b6d176 100644 --- a/tests/native-config-compiler.test.ts +++ b/tests/native-config-compiler.test.ts @@ -6,6 +6,7 @@ import { compileNativeConfig, NATIVE_CONFIG_INPUT_LIMIT, readNativeConfigInput, + resolveNativeConfig, resolveNativeConfigCompilerBinary, } from "../src/lib/native-config-compiler.ts"; import { restoreEnv } from "./helpers/env.ts"; @@ -23,6 +24,144 @@ let directory = ""; beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), "hack-compiler-transport-")); }); + +const LOCAL_RESOLUTION = { + overlay: "qa", + origin: "checkout_local", + auto_branch: true, + inherit_local: true, + resolution_hash: "b".repeat(64), +} as const; + +function resolverScript(body: string): string { + return `if (process.argv[2] === '--protocol') { console.log(${JSON.stringify(JSON.stringify({ ...PROTOCOL, resolve_version: 1, local_version: 1 }))}); } else { ${body} }`; +} + +test("local resolution preserves original document text and explicit tri-state", async () => { + const local = + '\ufeff{"schema_version":1,"environment":{"default_overlay":"qa","default_overlay":null}}'; + const binary = await fixture( + resolverScript( + `const received = JSON.parse(await Bun.stdin.text()); console.log(JSON.stringify({ ...${JSON.stringify(SUCCESS)}, plan: {plan_version:1, received, arguments:process.argv.slice(2), keys:Object.keys(process.env).sort()}, local_resolution:${JSON.stringify(LOCAL_RESOLUTION)} }));` + ) + ); + for (const explicitOverlay of [undefined, null, "qa"]) { + const result = await resolveNativeConfig({ + input: INPUT, + checkoutLocal: new TextEncoder().encode(local), + explicitOverlay, + binary, + profiles: ["qa"], + }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("Expected local resolution success"); + } + expect(result.plan.received).toEqual({ + request_version: 1, + project: new TextDecoder().decode(INPUT), + checkout_local: local, + ...(explicitOverlay === undefined + ? {} + : { explicit_overlay: explicitOverlay }), + }); + expect(result.plan.arguments).toEqual(["resolve", "--profile", "qa"]); + expect(result.plan.keys).toEqual(["PATH"]); + expect(result.local_resolution).toEqual(LOCAL_RESOLUTION); + } +}); + +test("old compiler can compile explicit input but refuses project resolution before input", async () => { + const receipt = join(directory, "resolver-received-input"); + const binary = await fixture( + script( + `await Bun.write(${JSON.stringify(receipt)}, await Bun.stdin.text()); console.log(${JSON.stringify(JSON.stringify(SUCCESS))});` + ) + ); + await expect(resolveNativeConfig({ input: INPUT, binary })).rejects.toThrow( + "version mismatch" + ); + await expect( + compileNativeConfig({ input: INPUT, binary, requireLocalResolution: true }) + ).rejects.toThrow("version mismatch"); + expect(await Bun.file(receipt).exists()).toBe(false); + expect((await compileNativeConfig({ input: INPUT, binary })).ok).toBe(true); +}); + +test("local diagnostics retain role and fixed redacted messages", async () => { + const result = { + transport_version: 1, + ok: false, + diagnostics: [ + { + document: "checkout_local", + code: "duplicate_key", + pointer: "/environment", + message: "Duplicate JSON object keys are not allowed.", + line: 2, + column: 3, + }, + ], + } as const; + const binary = await fixture( + resolverScript( + `console.log(${JSON.stringify(JSON.stringify(result))}); process.exitCode=1;` + ) + ); + expect(await resolveNativeConfig({ input: INPUT, binary })).toEqual(result); +}); + +test.each([ + { ...LOCAL_RESOLUTION, overlay: "QA" }, + { ...LOCAL_RESOLUTION, origin: "private-output" }, + { ...LOCAL_RESOLUTION, inherit_local: null }, + { ...LOCAL_RESOLUTION, resolution_hash: "invalid" }, + undefined, +])("rejects invalid local resolution metadata without exposing it", async (local_resolution) => { + const binary = await fixture( + resolverScript( + `console.log(${JSON.stringify(JSON.stringify({ ...SUCCESS, local_resolution }))});` + ) + ); + await expect(resolveNativeConfig({ input: INPUT, binary })).rejects.toThrow( + "invalid result" + ); +}); + +test("local resolution refuses invalid UTF-8 and oversized documents before spawning", async () => { + const binary = join(directory, "absent"); + await expect( + resolveNativeConfig({ + input: INPUT, + checkoutLocal: new Uint8Array([255]), + binary, + }) + ).rejects.toThrow("checkout_local input must be valid UTF-8"); + await expect( + resolveNativeConfig({ + input: INPUT, + primaryLocal: new Uint8Array(NATIVE_CONFIG_INPUT_LIMIT + 1), + binary, + }) + ).rejects.toThrow("input budget"); + await expect( + resolveNativeConfig({ + input: INPUT, + explicitOverlay: "x".repeat(NATIVE_CONFIG_INPUT_LIMIT + 1), + binary, + }) + ).rejects.toThrow("selection exceeds the input budget"); + const escaped = new Uint8Array(NATIVE_CONFIG_INPUT_LIMIT); + await expect( + resolveNativeConfig({ + input: escaped, + primaryLocal: escaped, + checkoutLocal: escaped, + explicitOverlay: "\u0000".repeat(NATIVE_CONFIG_INPUT_LIMIT), + binary, + }) + ).rejects.toThrow("request exceeds the input budget"); +}); afterEach(async () => { await rm(directory, { recursive: true, force: true }); }); diff --git a/tests/native-config-dto.test.ts b/tests/native-config-dto.test.ts index 4a8677217..8c47c5613 100644 --- a/tests/native-config-dto.test.ts +++ b/tests/native-config-dto.test.ts @@ -1,5 +1,8 @@ import { expect, test } from "bun:test"; -import type { Project } from "../packages/config-compiler/generated/native-config.ts"; +import type { + LocalConfig, + Project, +} from "../packages/config-compiler/generated/native-config.ts"; // Generated DTOs are projections; only Rust owns semantic validation. const minimal: Project = { schema_version: 1, name: "example" }; @@ -25,6 +28,37 @@ const falseTombstone: Project = { }; void [future, nullOverlay, unknown, falseTombstone]; +const localMissing: LocalConfig = { schema_version: 1 }; +const localNull: LocalConfig = { + schema_version: 1, + environment: { default_overlay: null }, +}; +const localNamed: LocalConfig = { + schema_version: 1, + environment: { default_overlay: "qa" }, +}; +const localInvalid: LocalConfig = { + schema_version: 1, + // @ts-expect-error local configuration cannot declare workloads + services: {}, +}; +const localFuture: LocalConfig = { + // @ts-expect-error local version is independently fenced + schema_version: 2, +}; +const localNullEnvironment: LocalConfig = { + schema_version: 1, + // @ts-expect-error only overlay selection permits null + environment: null, +}; +void [localInvalid, localFuture, localNullEnvironment]; + test("authored DTO permits omitted default fields", () => { expect(minimal).toEqual({ schema_version: 1, name: "example" }); }); + +test("local DTO preserves omitted, base and named overlay choices", () => { + expect(localMissing).toEqual({ schema_version: 1 }); + expect(localNull.environment?.default_overlay).toBeNull(); + expect(localNamed.environment?.default_overlay).toBe("qa"); +}); diff --git a/tests/native-project-inputs-acquisition-race.test.ts b/tests/native-project-inputs-acquisition-race.test.ts new file mode 100644 index 000000000..b32fb63b7 --- /dev/null +++ b/tests/native-project-inputs-acquisition-race.test.ts @@ -0,0 +1,133 @@ +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + expect, + test, +} from "bun:test"; +import { + mkdir, + mkdtemp, + realpath, + rename, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { HackCliError } from "../src/lib/cli-result.ts"; +import { registerScopedModuleMock } from "./helpers/scoped-module-mock.ts"; + +type AcquisitionChange = "replace" | "rewrite" | "parent" | "denied"; +let root: string; +let targetFile: string | null = null; +let change: AcquisitionChange = "replace"; +let closedFiles = 0; +const { open: realOpen } = await import("node:fs/promises"); + +const fileMock = await registerScopedModuleMock({ + importerPath: import.meta.path, + specifier: "node:fs/promises", + overrides: { + open: async (...args: Parameters) => { + const path = args[0]; + const target = path === targetFile; + if (target && typeof path === "string") { + if (change === "denied") { + throw new Error(`authored-canary: denied ${path}`); + } + if (change === "replace") { + await rm(path); + await Bun.write(path, "authored-canary-replacement\n"); + } else if (change === "parent") { + const original = dirname(path); + const displaced = `${original}-displaced`; + await rename(original, displaced); + await symlink(displaced, original); + } + } + const file = await realOpen(...args); + if (!target || typeof path !== "string") { + return file; + } + let rewritten = false; + return new Proxy(file, { + get: (handle, property) => { + if (property === "close") { + return async () => { + closedFiles += 1; + await handle.close(); + }; + } + if (property === "read" && change === "rewrite") { + return async (...readArgs: unknown[]) => { + const result: unknown = await Reflect.apply( + handle.read, + handle, + readArgs + ); + if (!rewritten) { + rewritten = true; + await Bun.write(path, '{"a":2}\n'); + } + return result; + }; + } + const value: unknown = Reflect.get(handle, property, handle); + return typeof value === "function" ? value.bind(handle) : value; + }, + }); + }, + }, +}); + +const { acquireNativeLocalInputs, acquireNativeProjectInput } = await import( + "../src/lib/native-project-inputs.ts" +); + +beforeAll(() => fileMock.activate()); +afterAll(() => fileMock.deactivate()); +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), "native-input-race-"))); + targetFile = null; + closedFiles = 0; + await mkdir(join(root, ".hack")); + await Bun.write(join(root, ".hack", "hack.project.json"), '{"a":1}\n'); +}); +afterEach(async () => { + await rm(root, { recursive: true, force: true }); +}); + +for (const role of ["project", "checkout-local"] as const) { + for (const mutation of ["replace", "rewrite", "parent", "denied"] as const) { + test(`${role} acquisition refuses ${mutation} at the descriptor boundary and closes owned files`, async () => { + targetFile = join( + root, + ".hack", + role === "project" ? "hack.project.json" : "hack.local.json" + ); + if (role === "checkout-local") { + await Bun.write(targetFile, '{"a":1}\n'); + } + change = mutation; + try { + if (role === "project") { + await acquireNativeProjectInput({ startDir: root }); + } else { + await acquireNativeLocalInputs({ + projectRoot: root, + inheritLocal: false, + }); + } + throw new Error("Unexpected acquisition success"); + } catch (error: unknown) { + expect(error).toBeInstanceOf(HackCliError); + expect(error).toHaveProperty("code", "E_CONFIG_INVALID"); + expect(String(error)).not.toContain(root); + expect(String(error)).not.toContain("authored-canary"); + } + expect(closedFiles).toBe(mutation === "denied" ? 0 : 1); + }); + } +} diff --git a/tests/native-project-inputs.test.ts b/tests/native-project-inputs.test.ts new file mode 100644 index 000000000..ecf3964a4 --- /dev/null +++ b/tests/native-project-inputs.test.ts @@ -0,0 +1,648 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { + chmod, + copyFile, + mkdir, + mkdtemp, + readdir, + realpath, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HackCliError, type HackErrorCode } from "../src/lib/cli-result.ts"; +import { + NATIVE_CONFIG_INPUT_LIMIT, + NativeConfigCompilerError, +} from "../src/lib/native-config-compiler.ts"; +import { + acquireNativeLocalInputs, + acquireNativeProjectInput, +} from "../src/lib/native-project-inputs.ts"; +import { resolveVerifiedPrimaryWorktreeRoot } from "../src/lib/worktree-local-config.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const ENV_KEYS = [ + "CI", + "HACK_EXECUTION_MODE", + "HACK_HOME", + "PATH", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "HACK_TEST_PRIVATE_ENV", +] as const; +let root: string; +let savedEnv: Record; + +beforeEach(async () => { + savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of [ + "CI", + "HACK_EXECUTION_MODE", + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + ]) { + Reflect.deleteProperty(process.env, key); + } + root = await realpath( + await mkdtemp(join(tmpdir(), "native-project-inputs-")) + ); + process.env.HACK_HOME = join(root, "hack-home"); +}); + +afterEach(async () => { + for (const key of ENV_KEYS) { + restoreEnv(key, savedEnv[key]); + } + await rm(root, { recursive: true, force: true }); +}); + +async function nativeProject( + name = "project", + input: string | Uint8Array = "{}\n" +) { + const projectRoot = join(root, name); + await Bun.write(join(projectRoot, ".hack", "hack.project.json"), input); + return projectRoot; +} + +async function git(opts: { + readonly projectRoot: string; + readonly args: readonly string[]; +}) { + const child = Bun.spawn(["git", "-C", opts.projectRoot, ...opts.args], { + stdin: "ignore", + stdout: "ignore", + stderr: "pipe", + }); + const [code, error] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + ]); + if (code !== 0) { + throw new Error(`Fixture Git failed: ${error}`); + } +} + +async function linkedFixture() { + const primaryRoot = await nativeProject("primary"); + await git({ + projectRoot: primaryRoot, + args: ["init", "--quiet", "-b", "main"], + }); + await git({ + projectRoot: primaryRoot, + args: ["add", "--force", ".hack/hack.project.json"], + }); + await git({ + projectRoot: primaryRoot, + args: [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "fixture", + ], + }); + const checkoutRoot = join(root, "linked"); + await git({ + projectRoot: primaryRoot, + args: ["worktree", "add", "--quiet", "-b", "linked", checkoutRoot], + }); + return { primaryRoot, checkoutRoot }; +} + +async function expectRedactedFailure( + operation: Promise, + code: HackErrorCode = "E_CONFIG_INVALID" +) { + try { + await operation; + throw new Error("Unexpected acquisition success"); + } catch (error: unknown) { + expect(error).toBeInstanceOf(HackCliError); + if (!(error instanceof HackCliError)) { + throw error; + } + expect(error.code).toBe(code); + expect(error.message).not.toContain(root); + expect(error.message).not.toContain("authored-canary"); + expect(error.cause).toBeUndefined(); + } +} + +test("native discovery returns the exact raw input without JSON decoding or ancestor fallback", async () => { + await Bun.write( + join(root, ".hack", "docker-compose.yml"), + "authored-canary: [\n" + ); + const input = Uint8Array.from([0xff, 0x00, 0x7b, 0x7d, 0x0a]); + const projectRoot = await nativeProject("nested/native", input); + const startDir = join(projectRoot, "src", "nested"); + await mkdir(startDir, { recursive: true }); + const names = await readdir(join(projectRoot, ".hack")); + + const acquired = await acquireNativeProjectInput({ startDir }); + + expect(acquired.projectRoot).toBe(projectRoot); + expect(acquired.input).toEqual(input); + expect(await readdir(join(projectRoot, ".hack"))).toEqual(names); + expect( + await Bun.file(join(process.env.HACK_HOME ?? "", "projects.json")).exists() + ).toBe(false); +}); + +for (const family of ["none", "legacy", "nested-legacy"] as const) { + test(`${family} discovery refuses with a fixed unsupported error`, async () => { + let startDir = join(root, "selected"); + await mkdir(startDir); + if (family === "legacy") { + await Bun.write( + join(startDir, ".hack", "docker-compose.yml"), + "authored-canary: [" + ); + } else if (family === "nested-legacy") { + const parent = await nativeProject("selected"); + startDir = join(parent, "child"); + await Bun.write( + join(startDir, ".dev", "docker-compose.yml"), + "authored-canary: [" + ); + } + await expectRedactedFailure( + acquireNativeProjectInput({ startDir }), + "E_NATIVE_PROJECT_UNSUPPORTED" + ); + }); +} + +for (const legacyDir of [".hack", ".dev"] as const) { + test(`native plus ${legacyDir} inputs retain the stable conflict error`, async () => { + const projectRoot = await nativeProject(); + await Bun.write( + join(projectRoot, legacyDir, "hack.config.json"), + "authored-canary: [" + ); + await expectRedactedFailure( + acquireNativeProjectInput({ startDir: projectRoot }), + "E_NATIVE_PROJECT_CONFLICT" + ); + await expectRedactedFailure( + acquireNativeLocalInputs({ projectRoot, inheritLocal: false }), + "E_NATIVE_PROJECT_CONFLICT" + ); + }); +} + +for (const kind of [ + "symlink", + "dangling", + "directory", + "fifo", + "unreadable", + "oversized", +] as const) { + test(`native project acquisition rejects ${kind} markers without reading values`, async () => { + const projectRoot = await nativeProject(); + const file = join(projectRoot, ".hack", "hack.project.json"); + await replaceUnsafeInput({ file, kind }); + await expectRedactedFailure( + acquireNativeProjectInput({ startDir: projectRoot }) + ); + }); +} + +test("redirected project roots and Hack directories are refused", async () => { + const projectRoot = await nativeProject(); + const alias = join(root, "alias"); + await symlink(projectRoot, alias); + await expectRedactedFailure(acquireNativeProjectInput({ startDir: alias })); + const redirectedRoot = join(root, "redirected"); + await mkdir(redirectedRoot); + await symlink(join(projectRoot, ".hack"), join(redirectedRoot, ".hack")); + await expectRedactedFailure( + acquireNativeProjectInput({ startDir: redirectedRoot }) + ); + await expectRedactedFailure( + acquireNativeLocalInputs({ + projectRoot: redirectedRoot, + inheritLocal: false, + }) + ); +}); + +test("the exact 1 MiB budget is accepted without allocating an unbounded read", async () => { + const input = new Uint8Array(NATIVE_CONFIG_INPUT_LIMIT).fill(0x20); + const projectRoot = await nativeProject("bounded", input); + expect( + (await acquireNativeProjectInput({ startDir: projectRoot })).input + ).toEqual(input); + await Bun.write(join(projectRoot, ".hack", "hack.local.json"), input); + expect( + (await acquireNativeLocalInputs({ projectRoot, inheritLocal: false })) + .checkoutLocal + ).toEqual(input); +}); + +test("missing locals remain absent and checkout null/invalid bytes remain raw", async () => { + const projectRoot = await nativeProject(); + expect( + await acquireNativeLocalInputs({ projectRoot, inheritLocal: true }) + ).toEqual({}); + for (const input of [ + new TextEncoder().encode(' {"overlay":null}\n'), + Uint8Array.from([0xff, 0x00]), + ]) { + await Bun.write(join(projectRoot, ".hack", "hack.local.json"), input); + expect( + await acquireNativeLocalInputs({ projectRoot, inheritLocal: true }) + ).toEqual({ checkoutLocal: input }); + } +}); + +test("real linked worktrees acquire both locals, observe edits, and never copy inputs", async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + const primaryLocal = new TextEncoder().encode( + ' {"overlay":"primary", "auto_branch":false}\n' + ); + const checkoutLocal = new TextEncoder().encode(' {"overlay":null}\n'); + await Bun.write(join(primaryRoot, ".hack", "hack.local.json"), primaryLocal); + expect( + await resolveVerifiedPrimaryWorktreeRoot({ projectRoot: checkoutRoot }) + ).toBe(primaryRoot); + expect( + await resolveVerifiedPrimaryWorktreeRoot({ projectRoot: primaryRoot }) + ).toBeNull(); + const before = await readdir(join(checkoutRoot, ".hack")); + expect( + await acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: true, + }) + ).toEqual({ primaryLocal }); + expect(await readdir(join(checkoutRoot, ".hack"))).toEqual(before); + await Bun.write( + join(checkoutRoot, ".hack", "hack.local.json"), + checkoutLocal + ); + expect( + await acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: true, + }) + ).toEqual({ primaryLocal, checkoutLocal }); + const updated = new TextEncoder().encode("null\n"); + await Bun.write(join(primaryRoot, ".hack", "hack.local.json"), updated); + expect( + await acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: true, + }) + ).toEqual({ primaryLocal: updated, checkoutLocal }); + await rm(join(primaryRoot, ".hack", "hack.local.json")); + expect( + await acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: true, + }) + ).toEqual({ checkoutLocal }); +}); + +for (const exclusion of [ + "optout", + "ci-true", + "ci-1", + "slim", + "codex", +] as const) { + test(`${exclusion} excludes primary inputs even when the primary family is incompatible`, async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + await rm(join(primaryRoot, ".hack", "hack.project.json")); + await Bun.write( + join(primaryRoot, ".hack", "docker-compose.yml"), + "authored-canary: [" + ); + await symlink( + join(root, "absent"), + join(primaryRoot, ".hack", "hack.local.json") + ); + const checkoutLocal = new TextEncoder().encode("null\n"); + await Bun.write( + join(checkoutRoot, ".hack", "hack.local.json"), + checkoutLocal + ); + if (exclusion === "ci-true" || exclusion === "ci-1") { + process.env.CI = exclusion === "ci-true" ? "true" : "1"; + } else if (exclusion === "slim" || exclusion === "codex") { + process.env.HACK_EXECUTION_MODE = exclusion; + } + expect( + await acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: exclusion !== "optout", + }) + ).toEqual({ checkoutLocal }); + }); +} + +for (const family of ["legacy", "conflict", "none"] as const) { + test(`a ${family} primary family refuses inheritance even when the primary local document is absent`, async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + if (family !== "conflict") { + await rm(join(primaryRoot, ".hack", "hack.project.json")); + } + if (family !== "none") { + await Bun.write( + join(primaryRoot, ".dev", "docker-compose.yml"), + "authored-canary: [" + ); + } + await expectRedactedFailure( + acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: true, + }), + family === "conflict" + ? "E_NATIVE_PROJECT_CONFLICT" + : "E_NATIVE_PROJECT_UNSUPPORTED" + ); + }); +} + +for (const origin of ["primary", "checkout"] as const) { + for (const kind of [ + "symlink", + "dangling", + "directory", + "fifo", + "unreadable", + "oversized", + ] as const) { + test(`${origin} local acquisition rejects ${kind} input`, async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + const file = join( + origin === "primary" ? primaryRoot : checkoutRoot, + ".hack", + "hack.local.json" + ); + await replaceUnsafeInput({ file, kind }); + await expectRedactedFailure( + acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: true, + }) + ); + }); + } +} + +test("a redirected primary Hack directory fails rather than dropping inherited configuration", async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + await rm(join(primaryRoot, ".hack"), { recursive: true }); + await symlink(join(checkoutRoot, ".hack"), join(primaryRoot, ".hack")); + await expectRedactedFailure( + acquireNativeLocalInputs({ projectRoot: checkoutRoot, inheritLocal: true }) + ); +}); + +test("a native subdirectory acquires only its own local input instead of ancestor worktree inheritance", async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + const projectRoot = join(checkoutRoot, "subproject"); + await Bun.write(join(projectRoot, ".hack", "hack.project.json"), "{}\n"); + await Bun.write( + join(primaryRoot, ".hack", "hack.local.json"), + "authored-canary\n" + ); + const checkoutLocal = new TextEncoder().encode("null\n"); + await Bun.write(join(projectRoot, ".hack", "hack.local.json"), checkoutLocal); + expect( + await acquireNativeLocalInputs({ projectRoot, inheritLocal: true }) + ).toEqual({ checkoutLocal }); + expect( + await acquireNativeLocalInputs({ projectRoot, inheritLocal: false }) + ).toEqual({ checkoutLocal }); +}); + +test("a copied worktree Git pointer cannot enroll an unrelated native project", async () => { + const { checkoutRoot } = await linkedFixture(); + const projectRoot = await nativeProject("forged"); + await copyFile(join(checkoutRoot, ".git"), join(projectRoot, ".git")); + await expectRedactedFailure( + acquireNativeLocalInputs({ projectRoot, inheritLocal: true }) + ); +}); + +test("the primary actual Git root must match its administrative-directory parent", async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + const alienRoot = await nativeProject("alien"); + await git({ + projectRoot: primaryRoot, + args: ["config", "extensions.worktreeConfig", "true"], + }); + await git({ + projectRoot: primaryRoot, + args: ["config", "--worktree", "core.worktree", alienRoot], + }); + await expectRedactedFailure( + acquireNativeLocalInputs({ projectRoot: checkoutRoot, inheritLocal: true }) + ); +}); + +test("caller Git redirection cannot choose a foreign primary", async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + const foreignRoot = await nativeProject("foreign"); + await git({ projectRoot: foreignRoot, args: ["init", "--quiet"] }); + const primaryLocal = new TextEncoder().encode("null\n"); + await Bun.write(join(primaryRoot, ".hack", "hack.local.json"), primaryLocal); + process.env.GIT_DIR = join(foreignRoot, ".git"); + process.env.GIT_COMMON_DIR = join(foreignRoot, ".git"); + process.env.GIT_WORK_TREE = checkoutRoot; + expect( + await acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: true, + }) + ).toEqual({ primaryLocal }); +}); + +test("unavailable Git fails for declared linkage and remains optional for standalone projects", async () => { + const { checkoutRoot } = await linkedFixture(); + const standalone = await nativeProject("standalone"); + process.env.PATH = join(root, "empty-path"); + await expectRedactedFailure( + acquireNativeLocalInputs({ projectRoot: checkoutRoot, inheritLocal: true }) + ); + expect( + await acquireNativeLocalInputs({ + projectRoot: standalone, + inheritLocal: true, + }) + ).toEqual({}); +}); + +test("native Git inspection does not inherit unrelated private runtime variables", async () => { + const { primaryRoot, checkoutRoot } = await linkedFixture(); + const realGit = Bun.which("git"); + if (!realGit) { + throw new Error("Fixture Git is unavailable"); + } + await fakeGit(` +if (Object.hasOwn(process.env, "HACK_TEST_PRIVATE_ENV")) process.exit(73); +const child = Bun.spawn([${JSON.stringify(realGit)}, ...process.argv.slice(2)], { + env: process.env, stdin: "ignore", stdout: "inherit", stderr: "ignore" +}); +process.exit(await child.exited); +`); + process.env.HACK_TEST_PRIVATE_ENV = "synthetic-private-runtime-canary"; + const primaryLocal = new TextEncoder().encode("null\n"); + await Bun.write(join(primaryRoot, ".hack", "hack.local.json"), primaryLocal); + expect( + await acquireNativeLocalInputs({ + projectRoot: checkoutRoot, + inheritLocal: true, + }) + ).toEqual({ primaryLocal }); +}); + +test("oversized Git output fails promptly and reaps the inspection process", async () => { + const projectRoot = await nativeProject(); + await mkdir(join(projectRoot, ".git")); + const pidFile = join(root, "inspection.pid"); + await fakeGit(` +await Bun.write(${JSON.stringify(pidFile)}, String(process.pid)); +process.stdout.write("x".repeat(1024 * 1024 + 1)); +setInterval(() => {}, 1000); +`); + await expectRedactedFailure( + acquireNativeLocalInputs({ projectRoot, inheritLocal: true }) + ); + await expectInspectionReaped(pidFile); +}); + +test("cancelled acquisition preserves its fixed compiler cancellation code", async () => { + const controller = new AbortController(); + controller.abort(); + const projectRoot = await nativeProject(); + for (const operation of [ + acquireNativeProjectInput({ + startDir: projectRoot, + signal: controller.signal, + }), + acquireNativeLocalInputs({ + projectRoot, + inheritLocal: true, + signal: controller.signal, + }), + ]) { + await expect(operation).rejects.toBeInstanceOf(NativeConfigCompilerError); + await expect(operation).rejects.toHaveProperty( + "code", + "E_COMPILER_CANCELLED" + ); + } +}); + +test("Git acquisition cancellation kills and reaps its owned inspection", async () => { + const projectRoot = await nativeProject(); + await mkdir(join(projectRoot, ".git")); + const pidFile = join(root, "inspection.pid"); + await fakeGit(` +await Bun.write(${JSON.stringify(pidFile)}, String(process.pid)); +setInterval(() => {}, 1000); +`); + const controller = new AbortController(); + const result = acquireNativeLocalInputs({ + projectRoot, + inheritLocal: true, + signal: controller.signal, + }).catch((error: unknown) => error); + for ( + let attempt = 0; + attempt < 200 && !(await Bun.file(pidFile).exists()); + attempt += 1 + ) { + await Bun.sleep(5); + } + expect(await Bun.file(pidFile).exists()).toBe(true); + controller.abort(); + const error = await result; + expect(error).toBeInstanceOf(NativeConfigCompilerError); + expect(error).toHaveProperty("code", "E_COMPILER_CANCELLED"); + expect(String(error)).not.toContain(root); + await expectInspectionReaped(pidFile); +}); + +test("acquisition ignores environment and generated state paths and never creates registration", async () => { + const projectRoot = await nativeProject(); + await mkdir(join(projectRoot, ".hack", ".env")); + await symlink( + join(root, "absent-state"), + join(projectRoot, ".hack", ".internal") + ); + await mkdir(join(projectRoot, ".hack", "hack.env.local.yaml")); + const before = await readdir(join(projectRoot, ".hack")); + expect( + await acquireNativeLocalInputs({ projectRoot, inheritLocal: true }) + ).toEqual({}); + expect( + (await acquireNativeProjectInput({ startDir: projectRoot })).input + ).toEqual(new TextEncoder().encode("{}\n")); + expect(await readdir(join(projectRoot, ".hack"))).toEqual(before); + expect( + await Bun.file(join(process.env.HACK_HOME ?? "", "projects.json")).exists() + ).toBe(false); +}); + +async function replaceUnsafeInput(opts: { + readonly file: string; + readonly kind: + | "symlink" + | "dangling" + | "directory" + | "fifo" + | "unreadable" + | "oversized"; +}) { + await rm(opts.file, { force: true }); + if (opts.kind === "symlink") { + const target = join(root, "redirected-document"); + await Bun.write(target, "authored-canary\n"); + await symlink(target, opts.file); + } else if (opts.kind === "dangling") { + await symlink(join(root, "absent-document"), opts.file); + } else if (opts.kind === "directory") { + await mkdir(opts.file); + } else if (opts.kind === "fifo") { + const child = Bun.spawn(["mkfifo", opts.file], { + stdout: "ignore", + stderr: "ignore", + }); + expect(await child.exited).toBe(0); + } else if (opts.kind === "unreadable") { + await Bun.write(opts.file, "authored-canary\n"); + await chmod(opts.file, 0); + } else { + await Bun.write(opts.file, new Uint8Array(NATIVE_CONFIG_INPUT_LIMIT + 1)); + } +} + +async function fakeGit(script: string): Promise { + const binary = join(root, "bin", "git"); + await Bun.write(binary, `#!${process.execPath}\n${script}`); + await chmod(binary, 0o700); + process.env.PATH = join(root, "bin"); +} + +async function expectInspectionReaped(pidFile: string): Promise { + const pid = Number(await Bun.file(pidFile).text()); + expect(Number.isSafeInteger(pid) && pid > 0).toBe(true); + expect(() => process.kill(pid, 0)).toThrow(); +} diff --git a/tests/prerelease-plan.test.ts b/tests/prerelease-plan.test.ts index c0f21a1b0..4b85d9227 100644 --- a/tests/prerelease-plan.test.ts +++ b/tests/prerelease-plan.test.ts @@ -514,6 +514,10 @@ async function compilerFixture(bundle: string) { mode: 0o600, }); await chmod(join(bundle, "hack.project.schema.json"), 0o600); + await writeFile(join(bundle, "hack.local.schema.json"), "{}\n", { + mode: 0o600, + }); + await chmod(join(bundle, "hack.local.schema.json"), 0o600); await Bun.write( join(bundle, "SHA256SUMS"), await renderChecksums({ @@ -523,7 +527,7 @@ async function compilerFixture(bundle: string) { ); } -test("packages the optional compiler/schema pair beside the CLI", async () => { +test("packages the compiler and both schemas beside the CLI", async () => { const root = await mkdtemp(join(tmpdir(), "hack-compiler-package-")); try { const bundle = await fixture(root); @@ -543,6 +547,71 @@ test("packages the optional compiler/schema pair beside the CLI", async () => { } }); +test("preserves the older complete compiler/project-schema pair", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-compiler-legacy-pair-")); + try { + const bundle = await fixture(root); + await compilerFixture(bundle); + await rm(join(bundle, "hack.local.schema.json")); + const names = [ + ...PRERELEASE_PAYLOAD, + "hack-config-compiler", + "hack.project.schema.json", + ]; + await Bun.write( + join(bundle, "SHA256SUMS"), + await renderChecksums({ root: bundle, names }) + ); + const plan = createPrereleasePlan(input); + const output = join(root, "assets"); + await packagePrerelease({ plan, bundle, output }); + expect( + (await archiveMembers(join(output, plan.archive))) + .map((entry) => entry.name) + .sort() + ).toEqual([...names, "SHA256SUMS"].sort()); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +for (const members of [ + ["hack-config-compiler"], + ["hack.project.schema.json"], + ["hack.local.schema.json"], + ["hack-config-compiler", "hack.local.schema.json"], + ["hack.project.schema.json", "hack.local.schema.json"], +]) { + test(`refuses incomplete compiler inventory despite matching checksums: ${members.join(",")}`, async () => { + const root = await mkdtemp(join(tmpdir(), "hack-compiler-inventory-")); + try { + const bundle = await fixture(root); + await compilerFixture(bundle); + for (const name of CONFIG_COMPILER_PAYLOAD) { + if (!members.includes(name)) { + await rm(join(bundle, name)); + } + } + await Bun.write( + join(bundle, "SHA256SUMS"), + await renderChecksums({ + root: bundle, + names: [...PRERELEASE_PAYLOAD, ...members], + }) + ); + await expect( + packagePrerelease({ + plan: createPrereleasePlan(input), + bundle, + output: join(root, "refused"), + }) + ).rejects.toThrow("requires the compiler and project schema"); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +} + for (const name of CONFIG_COMPILER_PAYLOAD) { for (const corruption of [ "missing", diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py index 553c02d36..399aba166 100644 --- a/tests/python/test_prerelease_install.py +++ b/tests/python/test_prerelease_install.py @@ -44,9 +44,11 @@ def with_mcp(entries, change=None): return [(name, value, tarfile.REGTYPE) for name, value in payload.items()] -def with_compiler(entries): +def with_compiler(entries, local=True): payload = {name: value for name, value, _ in entries if name != "SHA256SUMS"} payload.update({"hack-config-compiler": b"synthetic compiler", "hack.project.schema.json": b"{}\n"}) + if local: + payload["hack.local.schema.json"] = b"{}\n" payload["SHA256SUMS"] = "".join(sha(value) + " " + name + "\n" for name, value in sorted(payload.items())).encode() return [(name, value, tarfile.REGTYPE) for name, value in payload.items()] @@ -290,18 +292,20 @@ def test_compiler_pair_upgrade_preserves_old_layouts_and_installed_modes(self): archive, checksum = self.archive("5.0.0-next.2", with_mcp) with self.channel.lock(): self.channel.install("5.0.0-next.2", archive, checksum, True) - for number, mutate in ((3, with_compiler), (4, lambda entries: with_compiler(with_mcp(entries)))): + for number, mutate in ((3, lambda entries: with_compiler(entries, local=False)), + (4, with_compiler), (5, lambda entries: with_compiler(with_mcp(entries)))): version = "5.0.0-next." + str(number) archive, checksum = self.archive(version, mutate) with self.channel.lock(): self.channel.install(version, archive, checksum, True) bundle = self.channel.root / "versions" / version / "bundle" _, manifest = installer.verify_bundle(bundle, version) - self.assertTrue(installer.COMPILER_PAYLOAD <= set(manifest)) - for name in installer.COMPILER_PAYLOAD: + compiler = installer.LEGACY_COMPILER_PAYLOAD if number == 3 else installer.COMPILER_PAYLOAD + self.assertEqual(set(manifest) & installer.COMPILER_PAYLOAD, compiler) + for name in compiler: self.assertEqual((bundle / name).stat().st_mode & 0o777, 0o755 if name == "hack-config-compiler" else 0o600) - for prior in ("5.0.0-next.1", "5.0.0-next.2", version): + for prior in ("5.0.0-next.1", "5.0.0-next.2", "5.0.0-next.3", version): with self.channel.lock(): self.channel.select(prior) self.assertEqual(self.selection()["selected"], prior) @@ -309,6 +313,22 @@ def test_compiler_pair_upgrade_preserves_old_layouts_and_installed_modes(self): if arguments[0] == "/usr/bin/codesign"} self.assertIn("hack-config-compiler", signed) self.assertNotIn("hack.project.schema.json", signed) + self.assertNotIn("hack.local.schema.json", signed) + + def test_incomplete_compiler_groups_refuse_even_matching_checksums(self): + invalid = (("hack-config-compiler",), ("hack.project.schema.json",), ("hack.local.schema.json",), + ("hack-config-compiler", "hack.local.schema.json"), + ("hack.project.schema.json", "hack.local.schema.json")) + for members in invalid: + with self.subTest(members=members): + def mutate(entries): + payload = {name: value for name, value, _ in with_compiler(entries) + if name != "SHA256SUMS" and + (name not in installer.COMPILER_PAYLOAD or name in members)} + payload["SHA256SUMS"] = "".join(sha(value) + " " + name + "\n" + for name, value in sorted(payload.items())).encode() + return [(name, value, tarfile.REGTYPE) for name, value in payload.items()] + self.rejects_install(mutate, "Incomplete config compiler payload") def test_partial_tampered_and_aliased_compiler_archives_preserve_selection(self): for name in installer.COMPILER_PAYLOAD: @@ -319,7 +339,7 @@ def mutate(entries): case if case in (tarfile.SYMTYPE, tarfile.LNKTYPE) and key == name else kind) for key, value, kind in with_compiler(entries) if not (case == "missing" and key == name)] - self.rejects_install(mutate, "compiler payload|checksum mismatch|regular archive files") + self.rejects_install(mutate, "compiler payload|checksum mismatch|checksum entry|regular archive files") def test_installed_compiler_pair_tampering_and_modes_are_refused(self): archive, checksum = self.archive(mutate=with_compiler)