diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8149c56be..5c711df9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,42 @@ on: pull_request: jobs: + config-compiler: + name: Config compiler (${{ matrix.os }}) + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.4.2" + - uses: dtolnay/rust-toolchain@1.97.1 + with: + components: rustfmt, clippy + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: bun install --frozen-lockfile + - run: bun run check:config-compiler && bun run test:config-compiler + - run: bun run build:config-compiler + - name: Independently validate generated schema shape corpus + run: | + python3 -m venv "$RUNNER_TEMP/config-schema" + "$RUNNER_TEMP/config-schema/bin/python" -m pip install -r scripts/config-schema-requirements.txt + "$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 + bun run --cwd packages/cli typecheck + bun run build + bun scripts/check-config-compiler-cli.ts + state-models: name: Runtime state models runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 26de70b4d..2ffb4b6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,7 @@ dist # v5 candidate build and runtime state; never shares the installed v4 home .hack-local/ packages/runtime-core/target/ +packages/config-compiler/target/ # Local planning, historical evidence and retired documentation /_docs/ diff --git a/.hack/README.md b/.hack/README.md index 9d33408b8..c951812b8 100644 --- a/.hack/README.md +++ b/.hack/README.md @@ -16,6 +16,7 @@ hack run --profile toolchain toolchain -- test hack run --profile toolchain toolchain -- check hack run --profile toolchain toolchain -- rust hack run --profile toolchain toolchain -- rust-check +hack run --profile toolchain toolchain -- config-compiler hack run --profile toolchain toolchain -- build hack run --profile toolchain toolchain -- exec bun index.ts --help ``` diff --git a/.hack/toolchain/run.sh b/.hack/toolchain/run.sh index f83cf87fc..126bf2257 100644 --- a/.hack/toolchain/run.sh +++ b/.hack/toolchain/run.sh @@ -16,6 +16,14 @@ case "$task" in check) install_deps; bun run typecheck; exec bun run check ;; rust-check) cargo fmt --manifest-path packages/runtime-core/Cargo.toml --check; exec cargo clippy --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir /build/rust --all-targets --all-features --jobs 2 -- -D warnings ;; rust) exec cargo test --all-features --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir /build/rust --jobs 2 "$@" ;; + config-compiler) + install_deps + cargo fmt --manifest-path packages/config-compiler/Cargo.toml --check + cargo clippy --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir /build/config-compiler --all-targets --jobs 2 -- -D warnings + cargo test --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir /build/config-compiler --jobs 2 + export HACK_CONFIG_COMPILER_TARGET_DIR=/build/config-compiler + exec bun scripts/build-config-compiler.ts + ;; build) install_deps # Bun 1.3.9 stages in cwd; its cross-device fallback can emit zero-filled @@ -26,5 +34,5 @@ case "$task" in exec /app/dist/hack --version ;; exec) exec "$@" ;; - *) echo "Tasks: models, install, test [paths], check, rust [args], rust-check, build, exec " >&2; exit 2 ;; + *) echo "Tasks: models, install, test [paths], check, rust [args], rust-check, config-compiler, build, exec " >&2; exit 2 ;; esac diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 585c4b3a6..d7aef58d4 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -16,6 +16,7 @@ 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 under `mcp/BUNDLE_ID/`. Its manifest and native adapter, owner, and compiled backend are included in the outer checksums. The relay uses @@ -29,7 +30,7 @@ For versioned candidate packages, channel rules and publishing gates are describ in the [prerelease guide](https://github.com/hack-dance/hack/blob/next/docs/guides/prereleases.md), and the separate `hack-next` installation path is described in the [candidate installer guide](https://github.com/hack-dance/hack/blob/next/docs/guides/candidate-install.md). -The build re-signs the final compiled frontend and MCP backend with local ad-hoc signatures and +The build re-signs the compiled frontend, config compiler, and MCP backend with local ad-hoc signatures and strictly verifies all macOS executables before generating checksums. This checks code integrity; an ad-hoc signature does not establish a publisher identity or provide Apple notarization. Verify `SHA256SUMS` after copying the complete bundle. @@ -38,6 +39,14 @@ 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; +it does not migrate the retained runtime homes. + Shared MCP remains opt-in. Select the verified nested bundle with the existing installer; substitute `--cursor` or `--codex` as needed: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 2ed84189b..24919146b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -490,7 +490,7 @@ hack branch open [options] ## `hack config` -Read/write hack.config.json values +Read/write legacy config or validate an explicit native project file ### Usage @@ -504,6 +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 | ### Options @@ -568,6 +569,29 @@ hack config set [options] | `--help, -h` | Show help | | `--version, -v` | Show version | +## `hack config validate` + +Validate an explicit native project file without starting workloads + +### Usage + +```bash +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. + +### Options + +| Option | Description | +| --- | --- | +| `--file ` | Required native project JSON file | +| `--profile ` | Comma-separated declared native profiles | +| `--json` | Output JSON (machine-readable) | +| `--no-interactive` | Never prompt: apply documented defaults or fail with E_INTERACTIVE_REQUIRED (also via HACK_NO_INTERACTIVE=1) | +| `--help, -h` | Show help | +| `--version, -v` | Show version | + ## `hack session` diff --git a/docs/reference/native-config-compiler.md b/docs/reference/native-config-compiler.md new file mode 100644 index 000000000..35254ff04 --- /dev/null +++ b/docs/reference/native-config-compiler.md @@ -0,0 +1,145 @@ +# Native config compiler foundation + +This is an experimental, pure compiler for a bounded subset of the planned +`.hack/hack.project.json` format. It 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. + +The standalone `packages/config-compiler` Rust package has no dependency on the +native runtime, virtualization, Docker, or platform provider APIs. It uses the +repository's pinned Rust 1.97.1 and committed Cargo lockfile when building. The +compiled executable requires no host Rust installation, network, or VM. + +## Supported authored core + +`schema_version` must be `1`; `name` is required. Services, jobs, storage, profiles +and project environment selection default to empty. Source defaults to +`{"root":".","mode":"host-mounted"}`. Unknown fields, explicit nulls and unsupported +versions refuse; omitted fields retain their documented defaults. + +```json +{ + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "command": { "exec": ["web", "--port", "3000"] }, + "working_directory": "/app", + "mounts": [{ "source": ".", "target": "/app", "access": "read-only" }], + "environment": { "TOKEN": { "env_ref": "TOKEN" } } + } + } +} +``` + +- Each workload selects exactly one `image` or `build`. Basic build accepts a + relative `context`, a relative `dockerfile` (default `Dockerfile`) and optional + `target`. Advanced build settings are not accepted in this slice. +- Omitted command preserves image defaults. `{ "exec": ["program", "argument"] }` + and `{ "shell": "explicit shell source" }` are distinct. Empty commands and NUL + bytes refuse. Argument order is preserved. +- Mounts select exactly one relative `source` or declared `storage`, an absolute + container `target` and explicit `access`: `read-only` or `read-write`. Targets + must be unique after lexical normalization. Mount order is preserved. +- Declared storage currently accepts only `kind: persistent`, `scope: worktree`. + This is a symbolic resource declaration; it grants no creation or deletion + authority. Cache, shared and external storage remain unsupported. +- Environment values use exactly one of `{literal:"public text"}`, + `{default:"public fallback"}`, `{env_ref:"KEY"}` or `{unset:true}`. Empty strings + are valid. `unset:false`, bare values, nulls and combined tags refuse. Variable + destination names match `[A-Za-z_][A-Za-z0-9_]*`; managed `env_ref` names match + the owning store's `[A-Z_][A-Z0-9_]*`. Public literals are authored configuration; + never copy secrets into them. The compiler never reads managed stores or process + environment. Required references stay symbolic; managed-layer selection, missing + keys, remapping collisions and secret delivery remain later owner/admission checks. +- 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. +- 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 + namespace. Duplicate edges and cycles refuse, including in disabled workloads. +- Readiness accepts tagged `exec`, `http` and `tcp` definitions. Exec adds + `command`; HTTP adds `port` and an absolute `path`; TCP adds `port`. All require + `interval`, `timeout` and positive `retries`. Durations are positive integer + `ms`, `s`, `m` or `h`, normalized to millisecond strings up to 4,294,967,295 ms. + Port range is 1–65,535. Preserving a check does not promise backend enforcement. +- Root `profiles:["dev"]` declares profiles. A workload's `profiles:["dev"]` + assigns membership. Unprofiled workloads are enabled by default; explicit + selections enable matching workloads. Unknown selections and dependencies from + enabled workloads onto disabled workloads refuse. Profiles do not automatically + enable dependencies or hide invalid authored definitions. + +Project, workload, storage, build target and profile names are canonical lowercase +ASCII letters/digits followed by letters/digits, `.`, `_` or `-`, at most 63 bytes. +Names and profile lists must be unique in their applicable namespace. Relative +paths use POSIX syntax, stay project-relative, and reject absolute paths, `..`, +backslashes and drive syntax. Lexical `.` and repeated separators normalize; no +filesystem or symlink resolution occurs. Working directories and mount targets +must be absolute POSIX container paths without `..`. + +Routes, 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 +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: + +```sh +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 +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}`. +- `hack-config-compiler compile [--profile NAME]...` reads one UTF-8 JSON document + from stdin through EOF. Input is limited to 1 MiB and 64 nested containers. These + are parser safety bounds, not container resource or workload-count limits. +- Success exits 0 and emits `{transport_version:1,ok:true,plan,semantic_hash}`. + The plan declares `plan_version:1`. Failure exits 1 and emits + `{transport_version:1,ok:false,diagnostics:[...]}`. One deterministic first + diagnostic contains a stable code, fixed redacted message, JSON pointer and + one-based line/byte-column. Semantic errors use the nearest authored value's + 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 generate DIR` writes deterministic + `hack.project.schema.json` (2020-12) and `native-config.ts` projections. + +Duplicate keys, including escaped-equivalent keys in nested objects and arrays, +are rejected before map insertion. Graph cycle checking is iterative. Serialization +orders object keys, profile sets and dependency sets deterministically, materializes +defaults, normalizes portable paths and durations, and preserves semantic command +and mount order. `semantic_hash` is lowercase SHA-256 of the serialized normalized +plan; it contains no managed secret values, ciphertext or host admission metadata. +It is not a freshness fence, artifact signature or resource identity. + +The JSON Schema and DTOs describe wire shape. Rust additionally enforces names, +paths, dependencies, cycles, profiles and other contextual rules. The shared shape +corpus is `packages/config-compiler/tests/fixtures/schema-corpus.json`; semantic +negative cases live in Rust tests. TypeScript types cannot enforce runtime limits +or reject extra properties supplied through untyped inputs. + +## Development checks + +```sh +bun run build:config-compiler +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 +``` + +Generated projections must match a fresh `generate` run. Cross-platform packaging, +CLI transport, installed-sidecar checks and schema-validator qualification are +separate integration gates; unit tests alone do not establish them. diff --git a/package.json b/package.json index 444a41325..9743e78af 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "dev": "bun run --cwd packages/cli dev", "build": "bun run --cwd packages/cli build", "build:local": "sh scripts/build-hack-local.sh", + "build:config-compiler": "bun scripts/build-config-compiler.ts", + "check:config-compiler": "cargo fmt --manifest-path packages/config-compiler/Cargo.toml --check && cargo clippy --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir .hack-local/config-compiler-target --all-targets --jobs 2 -- -D warnings", + "test:config-compiler": "cargo test --locked --manifest-path packages/config-compiler/Cargo.toml --target-dir .hack-local/config-compiler-target --jobs 2", "test:local": "cargo test --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir .hack-local/target --jobs 2", "check:local": "cargo fmt --manifest-path packages/runtime-core/Cargo.toml --check && cargo clippy --locked --manifest-path packages/runtime-core/Cargo.toml --target-dir .hack-local/target --all-targets --jobs 2 -- -D warnings", "build:runtime-image": "bun run --cwd packages/cli build:runtime-image", diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 233b3f896..1dbb9a2d3 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,6 +1,12 @@ { "extends": "../../tsconfig.json", - "include": ["index.ts", "../../src", "../../tests"], + "include": [ + "index.ts", + "../../src", + "../../tests", + "../../scripts/build-config-compiler.ts", + "../../scripts/check-config-compiler-cli.ts" + ], "exclude": [ "../../apps", "../../dist", diff --git a/packages/config-compiler/Cargo.lock b/packages/config-compiler/Cargo.lock new file mode 100644 index 000000000..7cbed32e7 --- /dev/null +++ b/packages/config-compiler/Cargo.lock @@ -0,0 +1,353 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "hack-config-compiler" +version = "0.1.0" +dependencies = [ + "schemars", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "ts-rs", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.6", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/packages/config-compiler/Cargo.toml b/packages/config-compiler/Cargo.toml new file mode 100644 index 000000000..47ce85526 --- /dev/null +++ b/packages/config-compiler/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "hack-config-compiler" +version = "0.1.0" +edition = "2024" +rust-version = "1.97.1" +publish = false + +[dependencies] +serde = { version = "=1.0.229", features = ["derive"] } +serde_json = "=1.0.151" +schemars = "=1.2.2" +ts-rs = "=12.0.1" +sha2 = "=0.10.9" +serde_path_to_error = "=0.1.20" diff --git a/packages/config-compiler/generated/hack.project.schema.json b/packages/config-compiler/generated/hack.project.schema.json new file mode 100644 index 000000000..dba1d2d35 --- /dev/null +++ b/packages/config-compiler/generated/hack.project.schema.json @@ -0,0 +1,509 @@ +{ + "$defs": { + "Access": { + "enum": [ + "read-only", + "read-write" + ], + "type": "string" + }, + "Build": { + "additionalProperties": false, + "properties": { + "context": { + "type": "string" + }, + "dockerfile": { + "default": "Dockerfile", + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "context" + ], + "type": "object" + }, + "Command": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "exec": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "exec" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "shell": { + "type": "string" + } + }, + "required": [ + "shell" + ], + "type": "object" + } + ] + }, + "Dependency": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "condition": { + "$ref": "#/$defs/ServiceCondition" + }, + "service": { + "type": "string" + } + }, + "required": [ + "service", + "condition" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "condition": { + "$ref": "#/$defs/JobCondition" + }, + "job": { + "type": "string" + } + }, + "required": [ + "job", + "condition" + ], + "type": "object" + } + ] + }, + "EnvironmentSelection": { + "additionalProperties": false, + "properties": { + "default_overlay": { + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$", + "type": "string" + } + }, + "type": "object" + }, + "EnvironmentValue": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "literal": { + "type": "string" + } + }, + "required": [ + "literal" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "default": { + "type": "string" + } + }, + "required": [ + "default" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "env_ref": { + "pattern": "^[A-Z_][A-Z0-9_]*$", + "type": "string" + } + }, + "required": [ + "env_ref" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "unset": { + "$ref": "#/$defs/True" + } + }, + "required": [ + "unset" + ], + "type": "object" + } + ] + }, + "JobCondition": { + "enum": [ + "completed" + ], + "type": "string" + }, + "Mount": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/$defs/Access" + }, + "source": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "source", + "target", + "access" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/$defs/Access" + }, + "storage": { + "type": "string" + }, + "target": { + "type": "string" + } + }, + "required": [ + "storage", + "target", + "access" + ], + "type": "object" + } + ] + }, + "Readiness": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "command": { + "$ref": "#/$defs/Command" + }, + "interval": { + "type": "string" + }, + "kind": { + "const": "exec", + "type": "string" + }, + "retries": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 1, + "type": "integer" + }, + "timeout": { + "type": "string" + } + }, + "required": [ + "kind", + "command", + "interval", + "timeout", + "retries" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "interval": { + "type": "string" + }, + "kind": { + "const": "http", + "type": "string" + }, + "path": { + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "retries": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 1, + "type": "integer" + }, + "timeout": { + "type": "string" + } + }, + "required": [ + "kind", + "port", + "path", + "interval", + "timeout", + "retries" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "interval": { + "type": "string" + }, + "kind": { + "const": "tcp", + "type": "string" + }, + "port": { + "format": "uint16", + "maximum": 65535, + "minimum": 1, + "type": "integer" + }, + "retries": { + "format": "uint32", + "maximum": 4294967295, + "minimum": 1, + "type": "integer" + }, + "timeout": { + "type": "string" + } + }, + "required": [ + "kind", + "port", + "interval", + "timeout", + "retries" + ], + "type": "object" + } + ] + }, + "ServiceCondition": { + "enum": [ + "started", + "ready" + ], + "type": "string" + }, + "Source": { + "additionalProperties": false, + "properties": { + "mode": { + "$ref": "#/$defs/SourceMode", + "default": "host-mounted" + }, + "root": { + "default": ".", + "type": "string" + } + }, + "type": "object" + }, + "SourceMode": { + "enum": [ + "host-mounted" + ], + "type": "string" + }, + "Storage": { + "additionalProperties": false, + "properties": { + "kind": { + "$ref": "#/$defs/StorageKind" + }, + "scope": { + "$ref": "#/$defs/StorageScope" + } + }, + "required": [ + "kind", + "scope" + ], + "type": "object" + }, + "StorageKind": { + "enum": [ + "persistent" + ], + "type": "string" + }, + "StorageScope": { + "enum": [ + "worktree" + ], + "type": "string" + }, + "True": { + "const": true, + "type": "boolean" + }, + "Workload": { + "additionalProperties": false, + "oneOf": [ + { + "not": { + "required": [ + "build" + ] + }, + "required": [ + "image" + ] + }, + { + "not": { + "required": [ + "image" + ] + }, + "required": [ + "build" + ] + } + ], + "properties": { + "build": { + "$ref": "#/$defs/Build" + }, + "command": { + "$ref": "#/$defs/Command" + }, + "depends_on": { + "default": [], + "items": { + "$ref": "#/$defs/Dependency" + }, + "type": "array" + }, + "environment": { + "additionalProperties": { + "$ref": "#/$defs/EnvironmentValue" + }, + "default": {}, + "type": "object" + }, + "image": { + "type": "string" + }, + "mounts": { + "default": [], + "items": { + "$ref": "#/$defs/Mount" + }, + "type": "array" + }, + "profiles": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "readiness": { + "$ref": "#/$defs/Readiness" + }, + "working_directory": { + "type": "string" + } + }, + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "environment": { + "$ref": "#/$defs/EnvironmentSelection", + "default": {} + }, + "jobs": { + "additionalProperties": { + "$ref": "#/$defs/Workload" + }, + "default": {}, + "type": "object" + }, + "name": { + "type": "string" + }, + "profiles": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "schema_version": { + "format": "uint32", + "maximum": 1, + "minimum": 1, + "type": "integer" + }, + "services": { + "additionalProperties": { + "$ref": "#/$defs/Workload" + }, + "default": {}, + "type": "object" + }, + "source": { + "$ref": "#/$defs/Source", + "default": { + "mode": "host-mounted", + "root": "." + } + }, + "storage": { + "additionalProperties": { + "$ref": "#/$defs/Storage" + }, + "default": {}, + "type": "object" + } + }, + "required": [ + "schema_version", + "name" + ], + "title": "Project", + "type": "object" +} diff --git a/packages/config-compiler/generated/native-config.ts b/packages/config-compiler/generated/native-config.ts new file mode 100644 index 000000000..46dac8290 --- /dev/null +++ b/packages/config-compiler/generated/native-config.ts @@ -0,0 +1,22 @@ +// Generated by hack-config-compiler; do not edit. +export type SourceMode = "host-mounted"; +export type Source = { root?: string, mode?: SourceMode, }; +export type EnvironmentSelection = { default_overlay?: string, }; +export type Build = { context: string, dockerfile?: string, target?: string, }; +export type Command = { exec: Array, } | { shell: string, }; +export type True = true; +export type EnvironmentValue = { literal: string, } | { default: string, } | { env_ref: string, } | { unset: True, }; +export type StorageKind = "persistent"; +export type StorageScope = "worktree"; +export type Storage = { kind: StorageKind, scope: StorageScope, }; +export type Access = "read-only" | "read-write"; +export type Mount = { source: string, target: string, access: Access, } | { storage: string, target: string, access: Access, }; +export type ServiceCondition = "started" | "ready"; +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 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, }; diff --git a/packages/config-compiler/rust-toolchain.toml b/packages/config-compiler/rust-toolchain.toml new file mode 100644 index 000000000..5ec586969 --- /dev/null +++ b/packages/config-compiler/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.97.1" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/packages/config-compiler/src/json.rs b/packages/config-compiler/src/json.rs new file mode 100644 index 000000000..12f9d6ec6 --- /dev/null +++ b/packages/config-compiler/src/json.rs @@ -0,0 +1,173 @@ +use crate::Diagnostic; +use serde_json::{Map, Value}; +use std::collections::BTreeMap; + +pub const MAX_INPUT_BYTES: usize = 1024 * 1024; +const MAX_DEPTH: usize = 64; + +pub struct Document { + pub value: Value, + pub positions: BTreeMap, +} +struct Parser<'a> { + text: &'a str, + offset: usize, + line: usize, + column: usize, + positions: BTreeMap, +} + +pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_INPUT_BYTES { + return Err(Diagnostic::new("input_too_large", "", 1, 1)); + } + let text = std::str::from_utf8(bytes).map_err(|_| Diagnostic::new("invalid_utf8", "", 1, 1))?; + let mut parser = Parser { + text, + offset: 0, + line: 1, + column: 1, + positions: BTreeMap::new(), + }; + let value = parser.value("", 0)?; + parser.space(); + if parser.offset != text.len() { + return Err(parser.error("invalid_json", "")); + } + Ok(Document { + value, + positions: parser.positions, + }) +} + +pub fn child(parent: &str, key: &str) -> String { + format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1")) +} +impl Parser<'_> { + fn position(&self) -> (usize, usize) { + (self.line, self.column) + } + fn advance(&mut self) { + if self.byte() == Some(b'\n') { + self.line += 1; + self.column = 1; + } else { + self.column += 1; + } + self.offset += 1; + } + fn error(&self, code: &str, pointer: &str) -> Diagnostic { + let (line, column) = self.position(); + Diagnostic::new(code, pointer, line, column) + } + fn byte(&self) -> Option { + self.text.as_bytes().get(self.offset).copied() + } + fn space(&mut self) { + while matches!(self.byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) { + self.advance(); + } + } + fn consume(&mut self, value: u8, pointer: &str) -> Result<(), Diagnostic> { + self.space(); + if self.byte() != Some(value) { + return Err(self.error("invalid_json", pointer)); + } + self.advance(); + Ok(()) + } + fn string(&mut self, pointer: &str) -> Result { + let start = self.offset; + self.consume(b'"', pointer)?; + let mut escaped = false; + while let Some(byte) = self.byte() { + self.advance(); + if byte == b'"' && !escaped { + return serde_json::from_str(&self.text[start..self.offset]) + .map_err(|_| self.error("invalid_json", pointer)); + } + escaped = byte == b'\\' && !escaped; + } + Err(self.error("invalid_json", pointer)) + } + fn value(&mut self, pointer: &str, depth: usize) -> Result { + self.space(); + if depth > MAX_DEPTH { + return Err(self.error("depth_limit", pointer)); + } + self.positions.insert(pointer.into(), self.position()); + match self.byte() { + Some(b'{') => { + self.advance(); + self.space(); + let mut map = Map::new(); + if self.byte() == Some(b'}') { + self.advance(); + return Ok(Value::Object(map)); + } + loop { + self.space(); + let key_position = self.position(); + let key = self.string(pointer)?; + let nested = child(pointer, &key); + if map.contains_key(&key) { + return Err(Diagnostic::new( + "duplicate_key", + &nested, + key_position.0, + key_position.1, + )); + } + self.consume(b':', &nested)?; + let value = self.value(&nested, depth + 1)?; + map.insert(key, value); + self.space(); + match self.byte() { + Some(b'}') => { + self.advance(); + break; + } + Some(b',') => self.advance(), + _ => return Err(self.error("invalid_json", pointer)), + } + } + Ok(Value::Object(map)) + } + Some(b'[') => { + self.advance(); + self.space(); + let mut values = Vec::new(); + if self.byte() == Some(b']') { + self.advance(); + return Ok(Value::Array(values)); + } + loop { + values.push(self.value(&child(pointer, &values.len().to_string()), depth + 1)?); + self.space(); + match self.byte() { + Some(b']') => { + self.advance(); + break; + } + Some(b',') => self.advance(), + _ => return Err(self.error("invalid_json", pointer)), + } + } + Ok(Value::Array(values)) + } + Some(b'"') => self.string(pointer).map(Value::String), + Some(_) => { + let start = self.offset; + while let Some(byte) = self.byte() { + if matches!(byte, b' ' | b'\t' | b'\r' | b'\n' | b',' | b'}' | b']') { + break; + } + self.advance(); + } + let token = &self.text[start..self.offset]; + serde_json::from_str(token).map_err(|_| self.error("invalid_json", pointer)) + } + None => Err(self.error("invalid_json", pointer)), + } + } +} diff --git a/packages/config-compiler/src/lib.rs b/packages/config-compiler/src/lib.rs new file mode 100644 index 000000000..17de71a44 --- /dev/null +++ b/packages/config-compiler/src/lib.rs @@ -0,0 +1,217 @@ +//! Pure, bounded native configuration compiler. It performs no host admission or secret lookup. +mod json; +pub mod model; +mod validate; +pub use json::MAX_INPUT_BYTES; +use model::{Plan, Project}; +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use ts_rs::TS; + +#[derive(Debug, Clone, Serialize, JsonSchema, TS)] +pub struct Diagnostic { + pub code: String, + pub message: String, + pub pointer: String, + pub line: usize, + pub column: usize, +} +impl Diagnostic { + pub fn new(code: &str, pointer: &str, line: usize, column: usize) -> Self { + Self { + code: code.into(), + message: diagnostic_message(code).into(), + pointer: pointer.into(), + line, + column, + } + } +} +fn diagnostic_message(code: &str) -> &'static str { + match code { + "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.", + "depth_limit" => "Configuration exceeds the compiler nesting limit.", + "duplicate_key" => "Duplicate JSON object keys are not allowed.", + "unsupported_version" => "The authored schema version is not supported.", + "unknown_field" => "This field is not supported by this compiler.", + "invalid_shape" => "The value does not match the supported configuration shape.", + "invalid_name" => "Use a unique canonical name within the declared namespace.", + "invalid_path" => "The path must use the required portable relative or absolute form.", + "unknown_profile" => "The profile must be declared by the project.", + "duplicate_workload" => "Services and jobs must have distinct names.", + "unknown_dependency" => { + "The dependency must reference a declared target of the correct kind." + } + "missing_readiness" => "A ready dependency requires an explicit readiness check.", + "inactive_dependency" => "An active workload depends on a disabled profile target.", + "dependency_cycle" => "The workload dependency graph contains a cycle.", + "image_build_exclusive" => "Specify exactly one image or build definition.", + "invalid_image" => "The image reference must be nonempty and contain no whitespace.", + "invalid_command" => { + "Use a nonempty exec argument list or explicit shell command without NUL bytes." + } + "invalid_environment_key" => "Use a valid environment variable name.", + "invalid_environment_value" => "Environment values cannot contain NUL bytes.", + "unknown_storage" => "The mount must reference declared storage.", + "duplicate_mount_target" => "Mount targets must be unique after path normalization.", + "duplicate_dependency" => "Each dependency target may be declared only once.", + "invalid_readiness" => "The readiness check requires valid port, path and retry settings.", + "invalid_duration" => { + "Use a positive integer duration in ms, s, m or h within the supported millisecond range." + } + "input_read_failed" => "Configuration input could not be read.", + _ => "Configuration could not be compiled.", + } +} + +#[derive(Debug, Serialize, JsonSchema, TS)] +#[serde(untagged)] +pub enum CompileResult { + Success { + #[ts(type = "1")] + transport_version: u32, + #[ts(type = "true")] + ok: bool, + plan: Plan, + semantic_hash: String, + }, + Failure { + #[ts(type = "1")] + transport_version: u32, + #[ts(type = "false")] + ok: bool, + diagnostics: Vec, + }, +} +impl CompileResult { + pub fn failure(diagnostic: Diagnostic) -> Self { + Self::Failure { + transport_version: 1, + ok: false, + diagnostics: vec![diagnostic], + } + } +} + +/// Compiles explicit input and profile names only. Environment directives stay symbolic. +pub fn compile(bytes: &[u8], profiles: &[String]) -> CompileResult { + match compile_inner(bytes, profiles) { + Ok((plan, semantic_hash)) => CompileResult::Success { + transport_version: 1, + ok: true, + plan, + semantic_hash, + }, + Err(error) => CompileResult::failure(error), + } +} +fn compile_inner(bytes: &[u8], profiles: &[String]) -> Result<(Plan, String), Diagnostic> { + let document = json::parse(bytes)?; + let at = |code: &str, pointer: &str| diagnostic_at(&document.positions, code, pointer); + if let Some(version) = document.value.get("schema_version") + && version.is_u64() + && version.as_u64() != Some(1) + { + return Err(at("unsupported_version", "/schema_version")); + } + let project: Project = + 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) + } + _ => {} + } + } + // Serde error text may contain authored values. Classify it but never return it. + let code = if error.inner().to_string().starts_with("unknown field") { + "unknown_field" + } else { + "invalid_shape" + }; + at(code, &pointer) + })?; + let plan = validate::lower(project, profiles, &at)?; + let encoded = serde_json::to_vec(&plan).map_err(|_| at("encoding_failed", ""))?; + let semantic_hash = format!("{:x}", Sha256::digest(encoded)); + Ok((plan, semantic_hash)) +} +fn diagnostic_at( + positions: &BTreeMap, + code: &str, + pointer: &str, +) -> Diagnostic { + let mut location = pointer; + let position = loop { + if let Some(position) = positions.get(location) { + break *position; + } + match location.rsplit_once('/') { + Some((parent, _)) => location = parent, + None => break (1, 1), + } + }; + Diagnostic::new(code, pointer, position.0, position.1) +} + +/// Generates bundled schema and TypeScript projections of the Rust wire types. +pub fn artifacts() -> Result<(String, String), serde_json::Error> { + use model::*; + let mut schema = serde_json::to_value(schemars::schema_for!(Project))?; + // Cross-field choice enforced by lower() and independently exercised in the schema corpus. + schema["$defs"]["Workload"]["oneOf"] = serde_json::json!([ + {"required":["image"], "not":{"required":["build"]}}, + {"required":["build"], "not":{"required":["image"]}} + ]); + let schema = serde_json::to_string_pretty(&schema)? + "\n"; + let cfg = ts_rs::Config::default(); + let declarations = [ + SourceMode::decl(&cfg), + Source::decl(&cfg), + EnvironmentSelection::decl(&cfg), + Build::decl(&cfg), + Command::decl(&cfg), + True::decl(&cfg), + EnvironmentValue::decl(&cfg), + StorageKind::decl(&cfg), + StorageScope::decl(&cfg), + Storage::decl(&cfg), + Access::decl(&cfg), + Mount::decl(&cfg), + ServiceCondition::decl(&cfg), + JobCondition::decl(&cfg), + Dependency::decl(&cfg), + Readiness::decl(&cfg), + Workload::decl(&cfg), + Project::decl(&cfg), + Plan::decl(&cfg), + Diagnostic::decl(&cfg), + CompileResult::decl(&cfg), + ]; + Ok(( + schema, + format!( + "// Generated by hack-config-compiler; do not edit.\n{}\n", + declarations + .iter() + .map(|d| format!("export {d}")) + .collect::>() + .join("\n") + ), + )) +} + +pub fn protocol() -> Value { + serde_json::json!({"transport_version":1,"authored_version":1,"plan_version":1}) +} diff --git a/packages/config-compiler/src/main.rs b/packages/config-compiler/src/main.rs new file mode 100644 index 000000000..291bea10a --- /dev/null +++ b/packages/config-compiler/src/main.rs @@ -0,0 +1,72 @@ +use hack_config_compiler::{ + CompileResult, Diagnostic, MAX_INPUT_BYTES, artifacts, compile, protocol, +}; +use std::io::{self, Read, Write}; +use std::path::Path; + +fn emit(value: &impl serde::Serialize) -> Result<(), ()> { + let mut out = io::stdout().lock(); + serde_json::to_writer(&mut out, value).map_err(|_| ())?; + out.write_all(b"\n").map_err(|_| ()) +} +fn main() -> std::process::ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let status = match args.as_slice() { + [flag] if flag == "--protocol" => { + if emit(&protocol()).is_ok() { + 0 + } else { + 1 + } + } + [command, directory] if command == "generate" => generate(Path::new(directory)), + [command, rest @ ..] if command == "compile" => { + 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_INPUT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .is_err() + { + CompileResult::failure(Diagnostic::new("input_read_failed", "", 1, 1)) + } else { + compile(&bytes, &profiles) + }; + let failed = matches!(result, CompileResult::Failure { .. }); + if emit(&result).is_err() || failed { + 1 + } else { + 0 + } + } + _ => return usage(), + }; + std::process::ExitCode::from(status) +} +fn usage() -> std::process::ExitCode { + eprintln!( + "Usage: hack-config-compiler --protocol | compile [--profile NAME]... | generate DIR" + ); + std::process::ExitCode::from(2) +} +fn generate(directory: &Path) -> u8 { + let result = (|| -> Result<(), Box> { + let (schema, dto) = artifacts()?; + 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)?; + Ok(()) + })(); + if result.is_err() { + eprintln!("Compiler artifact generation failed."); + 1 + } else { + 0 + } +} diff --git a/packages/config-compiler/src/model.rs b/packages/config-compiler/src/model.rs new file mode 100644 index 000000000..6badb994d --- /dev/null +++ b/packages/config-compiler/src/model.rs @@ -0,0 +1,312 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::BTreeMap; +use ts_rs::TS; + +fn present<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + T::deserialize(d).map(Some) +} +fn dot() -> String { + ".".into() +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct Project { + #[schemars(range(min = 1, max = 1))] + #[ts(type = "1")] + pub schema_version: u32, + pub name: String, + #[serde(default)] + #[ts(as = "Option", optional)] + pub source: Source, + #[serde(default)] + #[ts(as = "Option>", optional)] + pub services: BTreeMap, + #[serde(default)] + #[ts(as = "Option>", optional)] + pub jobs: BTreeMap, + #[serde(default)] + #[ts(as = "Option>", optional)] + pub storage: BTreeMap, + #[serde(default)] + #[ts(as = "Option>", optional)] + pub profiles: Vec, + #[serde(default)] + #[ts(as = "Option", optional)] + pub environment: EnvironmentSelection, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct Source { + #[serde(default = "dot")] + #[ts(as = "Option", optional)] + pub root: String, + #[serde(default)] + #[ts(as = "Option", optional)] + pub mode: SourceMode, +} +impl Default for Source { + fn default() -> Self { + Self { + root: dot(), + mode: SourceMode::HostMounted, + } + } +} +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +pub enum SourceMode { + #[default] + HostMounted, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentSelection { + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + #[schemars(regex(pattern = "^[a-z0-9]+(?:-[a-z0-9]+)*$"))] + pub default_overlay: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct Workload { + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub image: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "Build")] + #[ts(optional, type = "Build")] + pub build: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "Command")] + #[ts(optional, type = "Command")] + pub command: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub working_directory: Option, + #[serde(default)] + #[ts(as = "Option>", optional)] + pub mounts: Vec, + #[serde(default)] + #[ts(as = "Option>", optional)] + pub environment: BTreeMap, + #[serde(default)] + #[ts(as = "Option>", optional)] + pub depends_on: Vec, + #[serde(default)] + #[ts(as = "Option>", optional)] + pub profiles: Vec, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "Readiness")] + #[ts(optional, type = "Readiness")] + pub readiness: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct Build { + pub context: String, + #[serde(default = "dockerfile")] + #[ts(as = "Option", optional)] + pub dockerfile: String, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "String")] + #[ts(optional, type = "string")] + pub target: Option, +} +fn dockerfile() -> String { + "Dockerfile".into() +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(untagged, deny_unknown_fields)] +pub enum Command { + Exec { + #[schemars(length(min = 1))] + exec: Vec, + }, + Shell { + shell: String, + }, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(untagged, deny_unknown_fields)] +pub enum EnvironmentValue { + Literal { + literal: String, + }, + Default { + default: String, + }, + Reference { + #[schemars(regex(pattern = "^[A-Z_][A-Z0-9_]*$"))] + env_ref: String, + }, + Unset { + unset: True, + }, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(try_from = "bool", into = "bool")] +#[schemars(with = "bool", extend("const" = true))] +#[ts(type = "true")] +pub struct True; +impl TryFrom for True { + type Error = &'static str; + fn try_from(value: bool) -> Result { + if value { + Ok(Self) + } else { + Err("expected true") + } + } +} +impl From for bool { + fn from(_: True) -> Self { + true + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +pub struct Storage { + pub kind: StorageKind, + pub scope: StorageScope, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum StorageKind { + Persistent, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum StorageScope { + Worktree, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(untagged, deny_unknown_fields)] +pub enum Mount { + Source { + source: String, + target: String, + access: Access, + }, + Storage { + storage: String, + target: String, + access: Access, + }, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +pub enum Access { + ReadOnly, + ReadWrite, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(untagged, deny_unknown_fields)] +pub enum Dependency { + Service { + service: String, + condition: ServiceCondition, + }, + Job { + job: String, + condition: JobCondition, + }, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum ServiceCondition { + Started, + Ready, +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum JobCondition { + Completed, +} +impl Dependency { + pub fn name(&self) -> &str { + match self { + Self::Service { service, .. } => service, + Self::Job { job, .. } => job, + } + } +} +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Readiness { + Exec { + command: Command, + interval: String, + timeout: String, + #[schemars(range(min = 1, max = 4294967295_u64))] + retries: u32, + }, + Http { + #[schemars(range(min = 1, max = 65535))] + port: u16, + path: String, + interval: String, + timeout: String, + #[schemars(range(min = 1, max = 4294967295_u64))] + retries: u32, + }, + Tcp { + #[schemars(range(min = 1, max = 65535))] + port: u16, + interval: String, + timeout: String, + #[schemars(range(min = 1, max = 4294967295_u64))] + retries: u32, + }, +} + +#[derive(Debug, Clone, Serialize, JsonSchema, TS)] +pub struct Plan { + #[ts(type = "1")] + pub plan_version: u32, + pub name: String, + pub source: Source, + pub environment: EnvironmentSelection, + pub selected_profiles: Vec, + pub storage: BTreeMap, + pub services: BTreeMap, + pub jobs: BTreeMap, +} diff --git a/packages/config-compiler/src/validate.rs b/packages/config-compiler/src/validate.rs new file mode 100644 index 000000000..82547282d --- /dev/null +++ b/packages/config-compiler/src/validate.rs @@ -0,0 +1,384 @@ +use crate::{Diagnostic, json::child, model::*}; +use std::collections::{BTreeMap, BTreeSet}; + +type At<'a> = dyn Fn(&str, &str) -> Diagnostic + 'a; +fn name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 63 + && value.bytes().enumerate().all(|(i, c)| { + c.is_ascii_lowercase() + || c.is_ascii_digit() + || (i > 0 && matches!(c, b'-' | b'_' | b'.')) + }) +} +fn env_name(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(i, c)| c.is_ascii_alphabetic() || c == b'_' || (i > 0 && c.is_ascii_digit())) +} +// 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 { + !value.is_empty() + && value.split('-').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) + }) +} +// References use the managed owner's PROJECT_ENV_KEY_PATTERN, not arbitrary process env keys. +fn managed_key(value: &str) -> bool { + !value.is_empty() + && value + .bytes() + .enumerate() + .all(|(i, b)| b.is_ascii_uppercase() || b == b'_' || (i > 0 && b.is_ascii_digit())) +} +fn relative(value: &str) -> Option { + if value.starts_with('/') || value.contains(['\\', '\0', ':']) || value.is_empty() { + return None; + } + let mut parts = Vec::new(); + for part in value.split('/') { + match part { + ".." => return None, + "" | "." => {} + other => parts.push(other), + } + } + Some(if parts.is_empty() { + ".".into() + } else { + parts.join("/") + }) +} +fn absolute(value: &str) -> Option { + if !value.starts_with('/') + || value.contains(['\\', '\0']) + || value.split('/').any(|p| p == "..") + { + return None; + } + Some(format!( + "/{}", + value + .split('/') + .filter(|p| !matches!(*p, "" | ".")) + .collect::>() + .join("/") + )) +} +fn duration(value: &str) -> Option { + let (digits, factor) = if let Some(s) = value.strip_suffix("ms") { + (s, 1) + } else if let Some(s) = value.strip_suffix('s') { + (s, 1000) + } else if let Some(s) = value.strip_suffix('m') { + (s, 60_000) + } else { + let s = value.strip_suffix('h')?; + (s, 3_600_000) + }; + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let n = digits.parse::().ok()?.checked_mul(factor)?; + (n > 0 && n <= u32::MAX as u64).then(|| format!("{n}ms")) +} +fn command(command: &Command, pointer: &str, at: &At) -> Result<(), Diagnostic> { + let valid = match command { + Command::Exec { exec } => { + !exec.is_empty() && !exec[0].is_empty() && exec.iter().all(|s| !s.contains('\0')) + } + Command::Shell { shell } => !shell.is_empty() && !shell.contains('\0'), + }; + if !valid { + return Err(at("invalid_command", pointer)); + } + Ok(()) +} +fn names(names: &mut [String], pointer: &str, at: &At) -> Result<(), Diagnostic> { + let mut seen = BTreeSet::new(); + for (i, n) in names.iter().enumerate() { + if !name(n) || !seen.insert(n) { + return Err(at("invalid_name", &child(pointer, &i.to_string()))); + } + } + names.sort(); + Ok(()) +} + +pub fn lower(mut project: Project, profiles: &[String], at: &At) -> Result { + if !name(&project.name) { + return Err(at("invalid_name", "/name")); + } + project.source.root = + relative(&project.source.root).ok_or_else(|| at("invalid_path", "/source/root"))?; + if project + .environment + .default_overlay + .as_ref() + .is_some_and(|s| !overlay_name(s)) + { + return Err(at("invalid_name", "/environment/default_overlay")); + } + names(&mut project.profiles, "/profiles", at)?; + let mut selected = profiles.to_vec(); + names(&mut selected, "/profiles", at)?; + if selected.iter().any(|p| !project.profiles.contains(p)) { + return Err(at("unknown_profile", "/profiles")); + } + for key in project.storage.keys() { + if !name(key) { + return Err(at("invalid_name", &child("/storage", key))); + } + } + for (kind, workloads) in [ + ("services", &mut project.services), + ("jobs", &mut project.jobs), + ] { + for (key, workload) in workloads.iter_mut() { + let pointer = child(&format!("/{kind}"), key); + if !name(key) { + return Err(at("invalid_name", &pointer)); + } + validate_workload(workload, &pointer, &project.profiles, &project.storage, at)?; + } + } + for key in project.services.keys() { + if project.jobs.contains_key(key) { + return Err(at("duplicate_workload", &child("/jobs", key))); + } + } + let all: BTreeMap<&str, (&str, &Workload)> = project + .services + .iter() + .map(|(k, v)| (k.as_str(), ("services", v))) + .chain(project.jobs.iter().map(|(k, v)| (k.as_str(), ("jobs", v)))) + .collect(); + let active = + |w: &Workload| w.profiles.is_empty() || w.profiles.iter().any(|p| selected.contains(p)); + for (key, (kind, w)) in &all { + for (index, dep) in w.depends_on.iter().enumerate() { + let pointer = format!("{}/depends_on/{index}", child(&format!("/{kind}"), key)); + let target = match dep { + Dependency::Service { service, .. } => project.services.get(service), + Dependency::Job { job, .. } => project.jobs.get(job), + } + .ok_or_else(|| at("unknown_dependency", &pointer))?; + if matches!( + dep, + Dependency::Service { + condition: ServiceCondition::Ready, + .. + } + ) && target.readiness.is_none() + { + return Err(at("missing_readiness", &pointer)); + } + if active(w) && !active(target) { + return Err(at("inactive_dependency", &pointer)); + } + } + } + check_cycles(&all, at)?; + project.services.retain(|_, w| active(w)); + project.jobs.retain(|_, w| active(w)); + for workload in project + .services + .values_mut() + .chain(project.jobs.values_mut()) + { + workload.depends_on.sort_by(|a, b| a.name().cmp(b.name())); + } + Ok(Plan { + plan_version: 1, + name: project.name, + source: project.source, + environment: project.environment, + selected_profiles: selected, + storage: project.storage, + services: project.services, + jobs: project.jobs, + }) +} +fn validate_workload( + workload: &mut Workload, + pointer: &str, + profiles: &[String], + storage: &BTreeMap, + at: &At, +) -> Result<(), Diagnostic> { + if workload.image.is_some() == workload.build.is_some() { + return Err(at("image_build_exclusive", pointer)); + } + if let Some(image) = &workload.image + && (image.is_empty() || image.chars().any(char::is_whitespace) || image.contains('\0')) + { + return Err(at("invalid_image", &child(pointer, "image"))); + } + if let Some(build) = &mut workload.build { + build.context = relative(&build.context) + .ok_or_else(|| at("invalid_path", &format!("{pointer}/build/context")))?; + build.dockerfile = relative(&build.dockerfile) + .filter(|p| p != ".") + .ok_or_else(|| at("invalid_path", &format!("{pointer}/build/dockerfile")))?; + if build.target.as_ref().is_some_and(|s| !name(s)) { + return Err(at("invalid_name", &format!("{pointer}/build/target"))); + } + } + if let Some(c) = &workload.command { + command(c, &child(pointer, "command"), at)?; + } + if let Some(wd) = &mut workload.working_directory { + *wd = + absolute(wd).ok_or_else(|| at("invalid_path", &child(pointer, "working_directory")))?; + } + names(&mut workload.profiles, &child(pointer, "profiles"), at)?; + if workload.profiles.iter().any(|p| !profiles.contains(p)) { + return Err(at("unknown_profile", &child(pointer, "profiles"))); + } + for (key, value) in &workload.environment { + let path = child(&child(pointer, "environment"), key); + if !env_name(key) { + return Err(at("invalid_environment_key", &path)); + } + match value { + EnvironmentValue::Reference { env_ref } if !managed_key(env_ref) => { + return Err(at("invalid_environment_key", &child(&path, "env_ref"))); + } + EnvironmentValue::Literal { literal: value } + | EnvironmentValue::Default { default: value } + if value.contains('\0') => + { + return Err(at("invalid_environment_value", &path)); + } + _ => {} + } + } + let mut targets = BTreeSet::new(); + for (index, mount) in workload.mounts.iter_mut().enumerate() { + let path = format!("{pointer}/mounts/{index}"); + let target = match mount { + Mount::Source { source, target, .. } => { + *source = + relative(source).ok_or_else(|| at("invalid_path", &child(&path, "source")))?; + target + } + Mount::Storage { + storage: key, + target, + .. + } => { + if !storage.contains_key(key) { + return Err(at("unknown_storage", &child(&path, "storage"))); + } + target + } + }; + *target = absolute(target).ok_or_else(|| at("invalid_path", &child(&path, "target")))?; + if !targets.insert(target.clone()) { + return Err(at("duplicate_mount_target", &child(&path, "target"))); + } + } + let mut deps = BTreeSet::new(); + for (i, dep) in workload.depends_on.iter().enumerate() { + if !deps.insert(dep.name()) { + return Err(at( + "duplicate_dependency", + &format!("{pointer}/depends_on/{i}"), + )); + } + } + // Reference validation occurs before sorting so diagnostics retain authored positions. + if let Some(readiness) = &mut workload.readiness { + let (interval, timeout, retries) = match readiness { + Readiness::Exec { + command: c, + interval, + timeout, + retries, + } => { + command(c, &format!("{pointer}/readiness/command"), at)?; + (interval, timeout, retries) + } + Readiness::Http { + port, + path, + interval, + timeout, + retries, + } => { + if *port == 0 || !path.starts_with('/') || path.contains(['\0', '\r', '\n']) { + return Err(at("invalid_readiness", &child(pointer, "readiness"))); + } + (interval, timeout, retries) + } + Readiness::Tcp { + port, + interval, + timeout, + retries, + } => { + if *port == 0 { + return Err(at("invalid_readiness", &child(pointer, "readiness"))); + } + (interval, timeout, retries) + } + }; + *interval = duration(interval) + .ok_or_else(|| at("invalid_duration", &format!("{pointer}/readiness/interval")))?; + *timeout = duration(timeout) + .ok_or_else(|| at("invalid_duration", &format!("{pointer}/readiness/timeout")))?; + if *retries == 0 { + return Err(at( + "invalid_readiness", + &format!("{pointer}/readiness/retries"), + )); + } + } + Ok(()) +} +/// Iterative cycle checking keeps deeply chained graphs off the call stack. +fn check_cycles(all: &BTreeMap<&str, (&str, &Workload)>, at: &At) -> Result<(), Diagnostic> { + let mut pending: BTreeMap<&str, usize> = all + .iter() + .map(|(name, (_, w))| (*name, w.depends_on.len())) + .collect(); + let mut dependants: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for (name, (_, w)) in all { + for dependency in &w.depends_on { + dependants.entry(dependency.name()).or_default().push(name); + } + } + let mut ready: BTreeSet<&str> = pending + .iter() + .filter(|(_, n)| **n == 0) + .map(|(name, _)| *name) + .collect(); + while let Some(name) = ready.pop_first() { + pending.remove(name); + if let Some(dependants) = dependants.get(name) { + for dependant in dependants { + if let Some(count) = pending.get_mut(dependant) { + *count -= 1; + if *count == 0 { + ready.insert(dependant); + } + } + } + } + } + if let Some((name, _)) = pending.first_key_value() + && let Some((kind, _)) = all.get(name) + { + return Err(at( + "dependency_cycle", + &format!("{}/depends_on", child(&format!("/{kind}"), name)), + )); + } + Ok(()) +} diff --git a/packages/config-compiler/tests/compiler.rs b/packages/config-compiler/tests/compiler.rs new file mode 100644 index 000000000..8c0ca9fd7 --- /dev/null +++ b/packages/config-compiler/tests/compiler.rs @@ -0,0 +1,295 @@ +use hack_config_compiler::{CompileResult, MAX_INPUT_BYTES, artifacts, compile}; +use serde_json::{Value, json}; +fn base() -> Value { + json!({"schema_version":1,"name":"example","services":{"web":{"image":"example/web:1"}}}) +} +fn result(v: &Value) -> Value { + serde_json::to_value(compile(&serde_json::to_vec(v).unwrap(), &[])).unwrap() +} +fn bad(v: &Value, code: &str) { + let r = result(v); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(r["diagnostics"][0]["code"], code, "{r}"); +} +#[test] +fn defaults_and_symbolic_environment_are_explicit() { + let mut v = base(); + v["services"]["web"]["environment"] = json!({"TOKEN":{"env_ref":"TOKEN"},"MODE":{"default":"dev"},"PUBLIC":{"literal":"yes"},"DROP":{"unset":true}}); + let r = result(&v); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!( + r["plan"]["source"], + json!({"root":".","mode":"host-mounted"}) + ); + assert_eq!( + r["plan"]["services"]["web"]["environment"], + v["services"]["web"]["environment"] + ); + assert_eq!(r["semantic_hash"].as_str().unwrap().len(), 64); +} +#[test] +fn duplicate_keys_are_refused_at_any_depth_before_replacement() { + for (text, pointer) in [ + (r#"{"schema_version":1,"name":"one","name":"two"}"#, "/name"), + ( + r#"{"schema_version":1,"name":"x","services":{"web":{"image":"a","image":"b"}}}"#, + "/services/web/image", + ), + ( + r#"{"schema_version":1,"name":"x","services":{"web":{"image":"a","mounts":[{"source":".","source":"b"}]}}}"#, + "/services/web/mounts/0/source", + ), + ( + r#"{"schema_version":1,"name":"x","na\u006de":"y"}"#, + "/name", + ), + ] { + let r = serde_json::to_value(compile(text.as_bytes(), &[])).unwrap(); + assert_eq!(r["diagnostics"][0]["code"], "duplicate_key"); + assert_eq!(r["diagnostics"][0]["pointer"], pointer); + assert!(r["diagnostics"][0]["column"].as_u64().unwrap() > 1); + } +} +#[test] +fn invalid_json_is_redacted_and_has_position() { + let r = serde_json::to_value(compile( + b"{\n\"schema_version\":1,\"name\": \"private-sentinel\",}", + &[], + )) + .unwrap(); + assert_eq!(r["diagnostics"][0]["code"], "invalid_json"); + assert_eq!(r["diagnostics"][0]["line"], 2); + assert!(!r.to_string().contains("private-sentinel")); + for text in ["{", "{} {}", "[1,]", "{\"a\":01}", "{\"a\":NaN}"] { + assert!(matches!( + compile(text.as_bytes(), &[]), + CompileResult::Failure { .. } + )); + } +} +#[test] +fn parsing_has_byte_depth_and_utf8_bounds() { + for (bytes, code) in [ + (vec![b' '; MAX_INPUT_BYTES + 1], "input_too_large"), + (vec![255], "invalid_utf8"), + ( + format!("{}0{}", "[".repeat(66), "]".repeat(66)).into_bytes(), + "depth_limit", + ), + ] { + let r = serde_json::to_value(compile(&bytes, &[])).unwrap(); + assert_eq!(r["diagnostics"][0]["code"], code); + } +} +#[test] +fn versions_unknown_fields_nulls_and_mixed_forms_refuse() { + let mut v = base(); + v["schema_version"] = json!(2); + bad(&v, "unsupported_version"); + v = base(); + v["routes"] = json!({}); + bad(&v, "unknown_field"); + v = base(); + v["services"]["web"]["backend_options"] = json!({}); + bad(&v, "unknown_field"); + for value in [ + Value::Null, + json!({"exec":[],"shell":"echo ignored"}), + json!("echo hi"), + ] { + v = base(); + v["services"]["web"]["command"] = value; + bad(&v, "invalid_shape"); + } + for value in [ + json!({"literal":"x","env_ref":"KEY"}), + json!({"unset":false}), + Value::Null, + json!("bare"), + ] { + v = base(); + v["services"]["web"]["environment"] = json!({"KEY":value}); + bad(&v, "invalid_shape"); + } + for field in [ + "image", + "build", + "command", + "working_directory", + "readiness", + ] { + v = base(); + v["services"]["web"][field] = Value::Null; + bad(&v, "invalid_shape"); + } +} +#[test] +fn command_and_image_build_contracts() { + let mut v = base(); + v["services"]["web"]["build"] = json!({"context":"."}); + bad(&v, "image_build_exclusive"); + v["services"]["web"] + .as_object_mut() + .unwrap() + .remove("image"); + assert_eq!(result(&v)["ok"], true); + assert_eq!( + result(&v)["plan"]["services"]["web"]["build"]["dockerfile"], + "Dockerfile" + ); + v["services"]["web"]["command"] = json!({"exec":[]}); + bad(&v, "invalid_command"); + v["services"]["web"]["command"] = json!({"shell":"echo hello"}); + assert_eq!(result(&v)["ok"], true); +} +#[test] +fn storage_and_paths_are_symbolic_and_checked() { + let mut v = base(); + v["storage"] = json!({"data":{"kind":"persistent","scope":"worktree"}}); + v["services"]["web"]["mounts"] = json!([{"source":"./src/.","target":"/app/.","access":"read-only"},{"storage":"data","target":"/data","access":"read-write"}]); + assert_eq!(result(&v)["ok"], true); + v["services"]["web"]["mounts"][1]["target"] = json!("/app"); + bad(&v, "duplicate_mount_target"); + v["services"]["web"]["mounts"][1]["target"] = json!("/data"); + v["services"]["web"]["mounts"][1]["storage"] = json!("missing"); + bad(&v, "unknown_storage"); + for path in ["/outside", "../outside", "C:\\host", "a/../../b"] { + v = base(); + v["source"] = json!({"root":path}); + bad(&v, "invalid_path"); + } +} +#[test] +fn dependencies_require_correct_kind_readiness_and_acyclic_namespace() { + let mut v = base(); + v["jobs"] = json!({"init":{"image":"init:1"}}); + v["services"]["web"]["depends_on"] = json!([{"job":"init","condition":"completed"}]); + assert_eq!(result(&v)["ok"], true); + v["jobs"]["init"]["depends_on"] = json!([{"service":"web","condition":"started"}]); + bad(&v, "dependency_cycle"); + v = base(); + v["services"]["web"]["depends_on"] = json!([{"service":"missing","condition":"started"}]); + bad(&v, "unknown_dependency"); + v["services"]["db"] = json!({"image":"db:1"}); + v["services"]["web"]["depends_on"] = json!([{"service":"db","condition":"ready"}]); + bad(&v, "missing_readiness"); + v["services"]["db"]["readiness"] = + json!({"kind":"tcp","port":5432,"interval":"1s","timeout":"500ms","retries":3}); + assert_eq!(result(&v)["ok"], true); + v["jobs"] = json!({"web":{"image":"job:1"}}); + bad(&v, "duplicate_workload"); +} +#[test] +fn profiles_are_explicit_and_cannot_hide_missing_dependencies() { + let mut v = base(); + v["profiles"] = json!(["dev"]); + v["jobs"] = json!({"init":{"image":"init:1","profiles":["dev"]}}); + assert!(result(&v)["plan"]["jobs"].as_object().unwrap().is_empty()); + let selected = + serde_json::to_value(compile(&serde_json::to_vec(&v).unwrap(), &["dev".into()])).unwrap(); + assert!(selected["plan"]["jobs"]["init"].is_object()); + v["services"]["web"]["depends_on"] = json!([{"job":"init","condition":"completed"}]); + bad(&v, "inactive_dependency"); + let r = serde_json::to_value(compile( + &serde_json::to_vec(&v).unwrap(), + &["unknown".into()], + )) + .unwrap(); + assert_eq!(r["diagnostics"][0]["code"], "unknown_profile"); +} +#[test] +fn semantic_hash_normalizes_defaults_paths_order_and_durations() { + let mut a = base(); + a["services"]["web"]["readiness"] = json!({"kind":"http","port":80,"path":"/health","interval":"1s","timeout":"1m","retries":3}); + let mut b = a.clone(); + b["source"] = json!({"root":"./.","mode":"host-mounted"}); + b["services"]["web"]["readiness"]["interval"] = json!("1000ms"); + b["services"]["web"]["readiness"]["timeout"] = json!("60000ms"); + assert_eq!(result(&a)["semantic_hash"], result(&b)["semantic_hash"]); + b["services"]["web"]["image"] = json!("example/web:2"); + assert_ne!(result(&a)["semantic_hash"], result(&b)["semantic_hash"]); +} +#[test] +fn schema_generation_is_deterministic_and_closed() { + let (schema, dto) = artifacts().unwrap(); + assert_eq!(artifacts().unwrap(), (schema.clone(), dto.clone())); + let v: Value = serde_json::from_str(&schema).unwrap(); + assert_eq!(v["$schema"], "https://json-schema.org/draft/2020-12/schema"); + assert_eq!(v["additionalProperties"], false); + assert_eq!(v["$defs"]["Workload"]["additionalProperties"], false); + assert!(dto.contains("env_ref")); + assert!(dto.contains("plan_version: 1")); +} + +#[test] +fn shared_schema_corpus_matches_rust_acceptance() { + let cases: Value = serde_json::from_str(include_str!("fixtures/schema-corpus.json")).unwrap(); + for case in cases.as_array().unwrap() { + assert_eq!( + result(&case["input"])["ok"], + case["valid"], + "{}", + case["name"] + ); + } +} + +#[test] +fn long_dependency_chains_do_not_use_recursive_graph_traversal() { + let mut v = base(); + for i in 0..2000 { + let mut job = json!({"image":"job:1"}); + if i > 0 { + job["depends_on"] = json!([{"job":format!("job{}",i-1),"condition":"completed"}]); + } + v["jobs"][format!("job{i}")] = job; + } + assert_eq!(result(&v)["ok"], true); + v["jobs"]["job0"]["depends_on"] = json!([{"job":"job1999","condition":"completed"}]); + bad(&v, "dependency_cycle"); +} + +#[test] +fn managed_overlay_and_reference_grammar_preserves_owner_selection() { + for name in ["qa", "qa-test", "qatest", "prod-2"] { + let mut v = base(); + v["environment"] = json!({"default_overlay":name}); + assert_eq!(result(&v)["ok"], true); + } + for name in [ + "qa_test", "qa.test", "qa-", "-qa", "qa--test", "QA", "qa/test", " qa ", "", + ] { + let mut v = base(); + v["environment"] = json!({"default_overlay":name}); + bad(&v, "invalid_name"); + } + for name in ["lowercase", "Mixed_CASE", "1KEY", ""] { + let mut v = base(); + v["services"]["web"]["environment"] = json!({"TARGET":{"env_ref":name}}); + bad(&v, "invalid_environment_key"); + } + let mut v = base(); + v["services"]["web"]["environment"] = json!({"lowercase_destination":{"env_ref":"VALID_KEY"},"lowercase_literal":{"literal":"public"}}); + assert_eq!(result(&v)["ok"], true); +} + +#[test] +fn diagnostic_columns_count_all_commas_and_utf8_bytes() { + for text in [ + "{\"schema_version\":1,\"name\":\"x\",\"name\":\"y\"}", + "{\"nested\":[0,1,{\"name\":\"é\",\"name\":\"y\"}]}", + "{\n\"nested\":[0,1,{\"name\":\"é\",\"name\":\"y\"}]}", + ] { + let r = serde_json::to_value(compile(text.as_bytes(), &[])).unwrap(); + let offset = text.rfind("\"name\"").unwrap(); + let prefix = &text[..offset]; + assert_eq!( + r["diagnostics"][0]["column"], + prefix.rsplit('\n').next().unwrap().len() + 1 + ); + assert_eq!( + r["diagnostics"][0]["line"], + prefix.bytes().filter(|b| *b == b'\n').count() + 1 + ); + } +} diff --git a/packages/config-compiler/tests/fixtures/schema-corpus.json b/packages/config-compiler/tests/fixtures/schema-corpus.json new file mode 100644 index 000000000..7259f16f1 --- /dev/null +++ b/packages/config-compiler/tests/fixtures/schema-corpus.json @@ -0,0 +1,856 @@ +[ + { + "name": "minimal", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + } + } + }, + { + "name": "exec", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "command": { + "exec": ["web", "--port", "3000"] + } + } + } + } + }, + { + "name": "shell", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "command": { + "shell": "exec web" + } + } + } + } + }, + { + "name": "environment-tags", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "environment": { + "TOKEN": { + "env_ref": "TOKEN" + }, + "MODE": { + "default": "dev" + }, + "PUBLIC": { + "literal": "yes" + }, + "DROP": { + "unset": true + } + } + } + } + } + }, + { + "name": "basic-build", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "build": { + "context": ".", + "target": "dev" + } + } + } + } + }, + { + "name": "http-readiness", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "port": 3000, + "path": "/health", + "interval": "1s", + "timeout": "500ms", + "retries": 3 + } + } + } + } + }, + { + "name": "refuse-routes", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + }, + "routes": {} + } + }, + { + "name": "refuse-schema_version", + "valid": false, + "input": { + "schema_version": 2, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + } + } + }, + { + "name": "refuse-unknown", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + }, + "unknown": "value" + } + }, + { + "name": "refuse-source", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + }, + "source": null + } + }, + { + "name": "refuse-workload-image-10", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": null + } + } + } + }, + { + "name": "refuse-workload-command-11", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "command": null + } + } + } + }, + { + "name": "refuse-workload-environment-12", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "environment": null + } + } + } + }, + { + "name": "refuse-workload-working_directory-13", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "working_directory": null + } + } + } + }, + { + "name": "refuse-workload-build-14", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "build": { + "context": "." + } + } + } + } + }, + { + "name": "refuse-workload-command-15", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "command": { + "exec": [] + } + } + } + } + }, + { + "name": "refuse-workload-command-16", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "command": { + "exec": ["x"], + "shell": "x" + } + } + } + } + }, + { + "name": "refuse-workload-mounts-17", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "mounts": [ + { + "source": ".", + "target": "/app" + } + ] + } + } + } + }, + { + "name": "refuse-workload-environment-18", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "environment": { + "KEY": { + "unset": false + } + } + } + } + } + }, + { + "name": "refuse-workload-environment-19", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "environment": { + "KEY": { + "literal": "x", + "default": "y" + } + } + } + } + } + }, + { + "name": "refuse-workload-backend_options-20", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "backend_options": {} + } + } + } + }, + { + "name": "tcp-retries-0", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "tcp", + "interval": "1s", + "timeout": "500ms", + "retries": 0, + "port": 80 + } + } + } + } + }, + { + "name": "tcp-retries-1", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "tcp", + "interval": "1s", + "timeout": "500ms", + "retries": 1, + "port": 80 + } + } + } + } + }, + { + "name": "tcp-retries-4294967295", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "tcp", + "interval": "1s", + "timeout": "500ms", + "retries": 4294967295, + "port": 80 + } + } + } + } + }, + { + "name": "tcp-retries-4294967296", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "tcp", + "interval": "1s", + "timeout": "500ms", + "retries": 4294967296, + "port": 80 + } + } + } + } + }, + { + "name": "tcp-port-0", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "tcp", + "interval": "1s", + "timeout": "500ms", + "retries": 3, + "port": 0 + } + } + } + } + }, + { + "name": "tcp-port-1", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "tcp", + "interval": "1s", + "timeout": "500ms", + "retries": 3, + "port": 1 + } + } + } + } + }, + { + "name": "tcp-port-65535", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "tcp", + "interval": "1s", + "timeout": "500ms", + "retries": 3, + "port": 65535 + } + } + } + } + }, + { + "name": "tcp-port-65536", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "tcp", + "interval": "1s", + "timeout": "500ms", + "retries": 3, + "port": 65536 + } + } + } + } + }, + { + "name": "http-retries-0", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "interval": "1s", + "timeout": "500ms", + "retries": 0, + "port": 80, + "path": "/health" + } + } + } + } + }, + { + "name": "http-retries-1", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "interval": "1s", + "timeout": "500ms", + "retries": 1, + "port": 80, + "path": "/health" + } + } + } + } + }, + { + "name": "http-retries-4294967295", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "interval": "1s", + "timeout": "500ms", + "retries": 4294967295, + "port": 80, + "path": "/health" + } + } + } + } + }, + { + "name": "http-retries-4294967296", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "interval": "1s", + "timeout": "500ms", + "retries": 4294967296, + "port": 80, + "path": "/health" + } + } + } + } + }, + { + "name": "http-port-0", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "interval": "1s", + "timeout": "500ms", + "retries": 3, + "port": 0, + "path": "/health" + } + } + } + } + }, + { + "name": "http-port-1", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "interval": "1s", + "timeout": "500ms", + "retries": 3, + "port": 1, + "path": "/health" + } + } + } + } + }, + { + "name": "http-port-65535", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "interval": "1s", + "timeout": "500ms", + "retries": 3, + "port": 65535, + "path": "/health" + } + } + } + } + }, + { + "name": "http-port-65536", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "http", + "interval": "1s", + "timeout": "500ms", + "retries": 3, + "port": 65536, + "path": "/health" + } + } + } + } + }, + { + "name": "exec-retries-0", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "exec", + "interval": "1s", + "timeout": "500ms", + "retries": 0, + "command": { + "exec": ["true"] + } + } + } + } + } + }, + { + "name": "exec-retries-1", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "exec", + "interval": "1s", + "timeout": "500ms", + "retries": 1, + "command": { + "exec": ["true"] + } + } + } + } + } + }, + { + "name": "exec-retries-4294967295", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "exec", + "interval": "1s", + "timeout": "500ms", + "retries": 4294967295, + "command": { + "exec": ["true"] + } + } + } + } + } + }, + { + "name": "exec-retries-4294967296", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "readiness": { + "kind": "exec", + "interval": "1s", + "timeout": "500ms", + "retries": 4294967296, + "command": { + "exec": ["true"] + } + } + } + } + } + }, + { + "name": "overlay-qa-test", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + }, + "environment": { + "default_overlay": "qa-test" + } + } + }, + { + "name": "overlay-qa_test", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + }, + "environment": { + "default_overlay": "qa_test" + } + } + }, + { + "name": "overlay-qa.test", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + }, + "environment": { + "default_overlay": "qa.test" + } + } + }, + { + "name": "overlay-qa-", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1" + } + }, + "environment": { + "default_overlay": "qa-" + } + } + }, + { + "name": "env-ref-VALID_KEY", + "valid": true, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "environment": { + "TARGET": { + "env_ref": "VALID_KEY" + } + } + } + } + } + }, + { + "name": "env-ref-lowercase", + "valid": false, + "input": { + "schema_version": 1, + "name": "example", + "services": { + "web": { + "image": "example/web:1", + "environment": { + "TARGET": { + "env_ref": "lowercase" + } + } + } + } + } + } +] diff --git a/packages/config-compiler/tests/protocol.rs b/packages/config-compiler/tests/protocol.rs new file mode 100644 index 000000000..e743a1d2d --- /dev/null +++ b/packages/config-compiler/tests/protocol.rs @@ -0,0 +1,76 @@ +use serde_json::Value; +use std::io::Write; +use std::process::{Command, Stdio}; + +fn run(args: &[&str], input: &[u8]) -> std::process::Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_hack-config-compiler")) + .args(args) + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap() +} +#[test] +fn handshake_and_compile_need_no_environment_or_host_tools() { + let handshake = run(&["--protocol"], b""); + assert!(handshake.status.success()); + 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}) + ); + let result = run(&["compile"], br#"{"schema_version":1,"name":"example"}"#); + assert!(result.status.success()); + assert!(result.stderr.is_empty()); + let value: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!(value["ok"], true); + assert_eq!(value["plan"]["plan_version"], 1); +} +#[test] +fn invalid_input_has_a_redacted_json_failure_and_exit_one() { + let result = run( + &["compile"], + br#"{"schema_version":1,"name":"private-sentinel",}"#, + ); + 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["ok"], false); + assert_eq!(value["diagnostics"][0]["code"], "invalid_json"); + assert!( + !String::from_utf8(result.stdout) + .unwrap() + .contains("private-sentinel") + ); +} +#[test] +fn unknown_flags_refuse_without_echoing_arguments() { + for args in [ + vec!["compile", "--profile"], + vec!["compile", "--unknown", "private-sentinel"], + vec!["--protocol", "private-sentinel"], + ] { + let result = run(&args, b""); + assert_eq!(result.status.code(), Some(2)); + assert!(result.stdout.is_empty()); + let stderr = String::from_utf8(result.stderr).unwrap(); + assert!(stderr.starts_with("Usage:")); + assert!(!stderr.contains("private-sentinel")); + } +} +#[test] +fn profile_selection_uses_explicit_repeatable_arguments() { + let input=br#"{"schema_version":1,"name":"example","profiles":["dev","test"],"jobs":{"check":{"image":"check:1","profiles":["test"]}}}"#; + let result = run(&["compile", "--profile", "test", "--profile", "dev"], input); + assert!(result.status.success()); + let value: Value = serde_json::from_slice(&result.stdout).unwrap(); + assert_eq!( + value["plan"]["selected_profiles"], + serde_json::json!(["dev", "test"]) + ); + assert!(value["plan"]["jobs"]["check"].is_object()); +} diff --git a/scripts/build-config-compiler.ts b/scripts/build-config-compiler.ts new file mode 100644 index 000000000..f41d5f9f9 --- /dev/null +++ b/scripts/build-config-compiler.ts @@ -0,0 +1,133 @@ +#!/usr/bin/env bun +import { + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; + +const GENERATED = ["hack.project.schema.json", "native-config.ts"] as const; + +/** Build the pure host compiler; verify projections before publishing local output. */ +export async function buildConfigCompiler(): Promise { + const root = resolve(import.meta.dir, ".."); + const toolchain = Bun.spawn(["rustc", "--version"], { + stdout: "pipe", + stderr: "inherit", + }); + const version = await new Response(toolchain.stdout).text(); + if ((await toolchain.exited) !== 0 || !version.startsWith("rustc 1.97.1 ")) { + throw new Error( + "Pinned Rust 1.97.1 is required to build the native configuration compiler." + ); + } + const target = + process.env.HACK_CONFIG_COMPILER_TARGET_DIR ?? + join(root, ".hack-local/config-compiler-target"); + if (!isAbsolute(target)) { + throw new Error("Compiler target directory must be absolute."); + } + const dist = join(root, "dist"); + const output = join(dist, "hack-config-compiler"); + for (const path of [join(root, ".hack-local"), target, dist, output]) { + await verifyConfigCompilerBuildPath({ path }); + } + await run( + [ + "cargo", + "build", + "--locked", + "--release", + "--jobs", + "2", + "--manifest-path", + "packages/config-compiler/Cargo.toml", + "--target-dir", + target, + ], + root + ); + const binary = join(target, "release/hack-config-compiler"); + // Cargo can hard-link its Linux target executable to the deps artifact. + // It is read-only input here; dist and shipped files must remain independent. + await verifyConfigCompilerBuildPath({ + path: binary, + allowCargoHardLink: true, + }); + const generated = await mkdtemp(join(tmpdir(), "hack-config-projections-")); + try { + await run([binary, "generate", generated], root); + for (const name of GENERATED) { + await verifyConfigCompilerBuildPath({ + path: join(root, "packages/config-compiler/generated", name), + }); + const [actual, expected] = await Promise.all([ + readFile(join(generated, name)), + readFile(join(root, "packages/config-compiler/generated", name)), + ]); + if (!actual.equals(expected)) { + throw new Error( + `Generated native configuration projection is stale: ${name}` + ); + } + } + } finally { + await rm(generated, { recursive: true, force: true }); + } + await mkdir(dist, { recursive: true }); + await copyFile(binary, output); + await chmod(output, 0o755); + process.stdout.write( + "Built matching native configuration compiler: dist/hack-config-compiler\n" + ); +} + +export async function verifyConfigCompilerBuildPath(opts: { + readonly path: string; + readonly allowCargoHardLink?: boolean; +}): Promise { + try { + const info = await lstat(opts.path); + if ( + info.isSymbolicLink() || + (info.isFile() && info.nlink !== 1 && !opts.allowCargoHardLink) + ) { + throw new Error("Refusing aliased configuration compiler build paths."); + } + } catch (error: unknown) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + return; + } + throw error; + } +} + +async function run(command: readonly string[], cwd: string): Promise { + const child = Bun.spawn([...command], { + cwd, + stdout: "inherit", + stderr: "inherit", + stdin: "ignore", + }); + if ((await child.exited) !== 0) { + throw new Error( + "Native configuration compiler build or generation failed." + ); + } +} + +if (import.meta.main) { + try { + await buildConfigCompiler(); + } catch (error: unknown) { + process.stderr.write( + `${error instanceof Error ? error.message : "Native configuration compiler build failed."}\n` + ); + process.exitCode = 1; + } +} diff --git a/scripts/build-native-candidate.sh b/scripts/build-native-candidate.sh index 185ec7c67..0da142232 100755 --- a/scripts/build-native-candidate.sh +++ b/scripts/build-native-candidate.sh @@ -78,6 +78,13 @@ if [ -n "$version" ]; then else bun build index.ts --compile --outfile "$out/hack-cli" 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" +chmod 755 "$out/hack-config-compiler" +chmod 600 "$out/hack.project.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; # preserve runtime metadata rather than trusting the embedded runtime's signature. /usr/bin/codesign --force --sign - --preserve-metadata=entitlements,flags,runtime "$out/hack-cli" @@ -109,6 +116,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 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 new file mode 100644 index 000000000..e09a3aee3 --- /dev/null +++ b/scripts/check-config-compiler-cli.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env bun +import { copyFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { isRecord } from "../src/lib/guards.ts"; + +/** Exercises a relocated compiled CLI + sidecar without source/Bun/Rust on PATH. */ +const root = resolve(import.meta.dir, ".."); +const directory = await mkdtemp(join(tmpdir(), "hack-config-cli-acceptance-")); +try { + const bundle = join(directory, "bundle"); + const home = join(directory, "home"); + const cwd = join(directory, "checkout"); + await Promise.all([mkdir(bundle), mkdir(home), mkdir(cwd)]); + for (const name of ["hack", "hack-config-compiler"]) { + await copyFile(join(root, "dist", name), join(bundle, name)); + } + const project = { + schema_version: 1, + name: "cli-fixture", + services: { + web: { + image: "example/web:1", + environment: { TOKEN: { env_ref: "TOKEN" } }, + }, + }, + }; + const file = join(cwd, "project.json"); + await Bun.write(file, JSON.stringify(project)); + const valid = await invoke(["config", "validate", "--file", file, "--json"]); + const success: unknown = JSON.parse(valid.stdout); + require(valid.exit === 0 && + isRecord(success) && + success.ok === true && + isRecord(success.plan), "real compiler success"); + require(valid.stdout.includes('"env_ref": "TOKEN"') && + !valid.stdout.includes( + "private-credential-sentinel" + ), "symbolic references"); + await Bun.write( + file, + '{"schema_version":1,"name":"cli-fixture","name":"private-credential-sentinel"}' + ); + const invalid = await invoke([ + "config", + "validate", + "--file", + file, + "--json", + ]); + const failure: unknown = JSON.parse(invalid.stdout); + require(invalid.exit === 1 && + isRecord(failure) && + failure.ok === false && + Array.isArray(failure.diagnostics) && + isRecord(failure.diagnostics[0]) && + failure.diagnostics[0].code === + "duplicate_key", "real compiler diagnostic forwarding"); + require(!( + invalid.stdout.includes("private-credential-sentinel") || + invalid.stderr.includes("private-credential-sentinel") + ), "diagnostic redaction"); + const usage = await invoke(["config", "validate", "--json"]); + require(usage.exit !== 0 && + (usage.stdout + usage.stderr).includes( + "--file" + ), "explicit file selection"); + await rm(join(bundle, "hack-config-compiler")); + const missing = await invoke([ + "config", + "validate", + "--file", + file, + "--json", + ]); + const missingResult: unknown = JSON.parse(missing.stdout); + require(missing.exit === 1 && + isRecord(missingResult) && + isRecord(missingResult.error) && + 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"); + process.stdout.write( + "Relocated compiled CLI acceptance: success, diagnostics, symbolic env, explicit file, missing sidecar, no state writes passed\n" + ); + + async function invoke(args: readonly string[]) { + const child = Bun.spawn([join(bundle, "hack"), ...args], { + cwd, + env: { + PATH: "/usr/bin:/bin", + HOME: home, + HACK_LOGGER: "console", + AWS_SECRET_ACCESS_KEY: "private-credential-sentinel", + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exit] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + return { stdout, stderr, exit }; + } +} finally { + await rm(directory, { recursive: true, force: true }); +} + +function require(condition: boolean, label: string): asserts condition { + if (!condition) { + throw new Error(`Native configuration CLI acceptance failed: ${label}`); + } +} diff --git a/scripts/check-config-schema.py b/scripts/check-config-schema.py new file mode 100644 index 000000000..eb359046f --- /dev/null +++ b/scripts/check-config-schema.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Check generated shape projection independently of Rust semantic validation.""" +import json +from pathlib import Path +import subprocess + +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()) + 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(), + 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") + + +if __name__ == "__main__": + main() diff --git a/scripts/config-schema-requirements.txt b/scripts/config-schema-requirements.txt new file mode 100644 index 000000000..12aab9370 --- /dev/null +++ b/scripts/config-schema-requirements.txt @@ -0,0 +1 @@ +jsonschema==4.25.1 diff --git a/scripts/install-prerelease.py b/scripts/install-prerelease.py index 6f1123d33..8efd89024 100644 --- a/scripts/install-prerelease.py +++ b/scripts/install-prerelease.py @@ -31,7 +31,9 @@ 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")) -EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5")) +COMPILER_PAYLOAD = frozenset(("hack-config-compiler", "hack.project.schema.json")) +EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5", + "hack-config-compiler")) BUNDLE_FILES = PAYLOAD | {"SHA256SUMS"} MCP_FILES = {"adapter": "hack-mcp-adapter", "owner": "hack-mcp-owner", "backend": "hack-mcp-backend"} @@ -41,10 +43,11 @@ REPOSITORY = "hack-dance/hack" DOWNLOAD_HOSTS = {"api.github.com", "github.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"} -# Original flat-layout manager, shipped before optional shared-MCP bundles. +# Reviewed flat-layout and shared-MCP managers, before optional compiler sidecars. # Keep this an explicit allowlist; a matching user-written receipt is not provenance. MANAGER_PREDECESSORS = frozenset({ "b7c49e3fec6b06790e833db1d2dcb441d2223c283b792713be46826aa2eef877", + "ca432b7fc6562bb091d17d3217f5d1daf9f91621a6919c8964ca51bee2111d0c", }) MANAGER_UPGRADE = ".manager-upgrade.json" @@ -179,10 +182,12 @@ def checksums(raw, expected): def payload_inventory(names): - """Accept the original flat payload or one complete content-addressed MCP bundle.""" + """Accept complete optional compiler and MCP groups alongside the original payload.""" names = set(names) require(BUNDLE_FILES <= names, "Incomplete candidate bundle.") - extra = names - BUNDLE_FILES + compiler = names & COMPILER_PAYLOAD + require(not compiler or compiler == COMPILER_PAYLOAD, "Incomplete config compiler payload.") + extra = names - BUNDLE_FILES - COMPILER_PAYLOAD if extra: matches = [MCP_MEMBER.fullmatch(name) for name in extra] require(all(matches), "Foreign candidate MCP payload.") @@ -215,7 +220,7 @@ def payload_mode(name): def verify_mcp_bundle(bundle, payload): - nested = sorted(set(payload) - PAYLOAD) + nested = sorted(name for name in payload if MCP_MEMBER.fullmatch(name)) if not nested: return prefix = str(Path(nested[0]).parent) @@ -267,7 +272,11 @@ def verify_bundle(bundle, version): def verify_signatures(bundle): names = ["hack-native", "hack-cli"] - names.extend(name for name in bundle_inventory(bundle) - PAYLOAD if not name.endswith("/manifest.json")) + payload = bundle_inventory(bundle) + if "hack-config-compiler" in payload: + names.append("hack-config-compiler") + names.extend(name for name in payload + if MCP_MEMBER.fullmatch(name) and not name.endswith("/manifest.json")) for name in names: try: result = subprocess.run(["/usr/bin/codesign", "--verify", "--strict", str(bundle / name)], @@ -289,8 +298,8 @@ def extract_archive(archive, checksum, destination, version, release_metadata=No seen = set() total = 0 for entry in source: - require(len(entries) < len(BUNDLE_FILES) + 4, "Incomplete or duplicate archive entries.") - require((entry.name in BUNDLE_FILES or MCP_MEMBER.fullmatch(entry.name)) and entry.name not in seen, + require(len(entries) < len(BUNDLE_FILES) + len(COMPILER_PAYLOAD) + 4, "Incomplete or duplicate archive entries.") + require((entry.name in BUNDLE_FILES | COMPILER_PAYLOAD or MCP_MEMBER.fullmatch(entry.name)) and entry.name not in seen, "Foreign, duplicate or traversing archive entry.") require(entry.isfile() and not entry.issparse() and entry.size > 0 and entry.size <= MAX_ARCHIVE, "Only bounded regular archive files are accepted.") @@ -321,7 +330,7 @@ def extract_archive(archive, checksum, destination, version, release_metadata=No value.update(chunk) require(value.hexdigest() == manifest[name], "Candidate checksum mismatch: " + name) destination.mkdir(mode=0o700) - nested = payload - PAYLOAD + nested = {name for name in payload if MCP_MEMBER.fullmatch(name)} if nested: prefix = Path(next(iter(nested))).parent (destination / "mcp").mkdir(mode=0o700) @@ -744,7 +753,7 @@ def install(self, version, archive=None, checksum=None, upgrade=False): # Manager replacement is a separate explicit, recoverable operation. require(set(manifest) <= PAYLOAD or digest(self.root / "manager.py") == digest(Path(__file__)), - "Shared MCP requires this channel's retained manager to match the installer. " + "Optional candidate payloads require this channel's retained manager to match the installer. " "Run upgrade-manager with this reviewed installer, or use a fresh --root; " "the existing channel and its selection are unchanged.") homes = {} diff --git a/scripts/prerelease-plan.ts b/scripts/prerelease-plan.ts index d99ad5fc6..1fe054ab5 100644 --- a/scripts/prerelease-plan.ts +++ b/scripts/prerelease-plan.ts @@ -13,6 +13,10 @@ export const PRERELEASE_PAYLOAD = [ "README.md", "prerelease.json", ] as const; +export const CONFIG_COMPILER_PAYLOAD = [ + "hack-config-compiler", + "hack.project.schema.json", +] as const; const VERSION = /^5\.0\.0-next\.[1-9][0-9]*$/; const REVISION = /^[0-9a-f]{40}$/; const STABLE_TAG = /^v(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)$/; @@ -396,16 +400,24 @@ export async function packagePrerelease({ if (!(await lstat(bundle)).isDirectory()) { 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 mcpPayload = await nativeCandidateMcpPayload(bundle); - const payload = [...PRERELEASE_PAYLOAD, ...mcpPayload]; + const payload = [...PRERELEASE_PAYLOAD, ...compilerPayload, ...mcpPayload]; const expected = [ ...PRERELEASE_PAYLOAD, + ...compilerPayload, "SHA256SUMS", ...(mcpPayload.length ? ["mcp"] : []), ].sort(); - if ( - JSON.stringify((await readdir(bundle)).sort()) !== JSON.stringify(expected) - ) { + if (JSON.stringify(entries.sort()) !== JSON.stringify(expected)) { throw new Error( "Native prerelease bundle must contain exactly the complete payload and checksums" ); @@ -415,6 +427,17 @@ export async function packagePrerelease({ if (!entry.isFile() || entry.nlink !== 1) { throw new Error(`Native bundle payload must be a regular file: ${name}`); } + if ( + name === "hack-config-compiler" || + name === "hack.project.schema.json" + ) { + const mode = name === "hack-config-compiler" ? 0o755 : 0o600; + if ((entry.mode & 0o7777) !== mode) { + throw new Error( + `Native config compiler payload has unsafe permissions: ${name}` + ); + } + } } const metadata = prereleaseMetadata({ version: plan.version, diff --git a/src/commands/config-validate.ts b/src/commands/config-validate.ts new file mode 100644 index 000000000..337fc59e8 --- /dev/null +++ b/src/commands/config-validate.ts @@ -0,0 +1,104 @@ +import { resolve } from "node:path"; +import { + CliUsageError, + defineCommand, + defineOption, + withHandler, +} from "../cli/command.ts"; +import { optJson } from "../cli/options.ts"; +import { + compileNativeConfig, + NativeConfigCompilerError, + readNativeConfigInput, +} from "../lib/native-config-compiler.ts"; + +const spec = defineCommand({ + name: "validate", + summary: + "Validate an explicit native project file 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.", + options: [ + defineOption({ + name: "file", + type: "string", + long: "--file", + valueHint: "", + description: "Required native project JSON file", + } as const), + defineOption({ + name: "profile", + type: "string", + long: "--profile", + valueHint: "", + description: "Comma-separated declared native profiles", + } as const), + optJson, + ], + positionals: [], + subcommands: [], +} as const); + +export const configValidateCommand = withHandler( + spec, + async ({ ctx, args }) => { + const file = args.options.file; + if (!file?.trim()) { + throw new CliUsageError("Native validation requires --file ."); + } + const profiles = args.options.profile + ?.split(",") + .map((name) => name.trim()); + if (profiles?.some((name) => name.length === 0)) { + throw new CliUsageError("Native profile names must not be empty."); + } + const controller = new AbortController(); + const cancel = () => controller.abort(); + process.once("SIGINT", cancel); + process.once("SIGTERM", cancel); + try { + const 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` + ); + } + } + return result.ok ? 0 : 1; + } catch (error: unknown) { + const failure = + error instanceof NativeConfigCompilerError + ? error + : new NativeConfigCompilerError( + "E_COMPILER_REQUEST", + "Native configuration validation failed." + ); + if (args.options.json) { + process.stdout.write( + `${JSON.stringify({ transport_version: 1, ok: false, error: { code: failure.code, message: failure.message } })}\n` + ); + } else { + process.stderr.write(`${failure.code}: ${failure.message}\n`); + } + return 1; + } finally { + process.removeListener("SIGINT", cancel); + process.removeListener("SIGTERM", cancel); + } + } +); diff --git a/src/commands/config.ts b/src/commands/config.ts index 045ec3c7e..6763d29d6 100644 --- a/src/commands/config.ts +++ b/src/commands/config.ts @@ -28,6 +28,7 @@ import { upsertProjectRegistration, } from "../lib/projects-registry.ts"; import { logger } from "../ui/logger.ts"; +import { configValidateCommand } from "./config-validate.ts"; type ConfigReadResult = | { @@ -39,7 +40,8 @@ type ConfigReadResult = const configSpec = defineCommand({ name: "config", - summary: "Read/write hack.config.json values", + summary: + "Read/write legacy config or validate an explicit native project file", group: "Project", options: [], positionals: [], @@ -203,6 +205,7 @@ export const configCommand = defineCommand({ subcommands: [ withHandler(configGetSpec, handleConfigGet), withHandler(configSetSpec, handleConfigSet), + configValidateCommand, ], } as const); diff --git a/src/lib/native-config-compiler.ts b/src/lib/native-config-compiler.ts new file mode 100644 index 000000000..6ee416954 --- /dev/null +++ b/src/lib/native-config-compiler.ts @@ -0,0 +1,337 @@ +import { stat } from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; +import { isRecord } from "./guards.ts"; + +export const NATIVE_CONFIG_INPUT_LIMIT = 1024 * 1024; +const OUTPUT_LIMIT = 8 * 1024 * 1024; +const STDERR_LIMIT = 64 * 1024; +const DEFAULT_TIMEOUT_MS = 10_000; +const HASH_PATTERN = /^[a-f0-9]{64}$/; + +export type NativeConfigDiagnostic = { + readonly code: string; + readonly pointer: string; + readonly message: string; + readonly line: number; + readonly column: number; +}; + +export type NativeConfigCompileResult = + | { + readonly transport_version: 1; + readonly ok: true; + readonly plan: Readonly>; + readonly semantic_hash: string; + } + | { + readonly transport_version: 1; + readonly ok: false; + readonly diagnostics: readonly NativeConfigDiagnostic[]; + }; + +/** Fixed transport failures never include compiler output or authored values. */ +export class NativeConfigCompilerError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = "NativeConfigCompilerError"; + this.code = code; + } +} + +/** Select a reviewed executable, never download or search PATH for a compiler. */ +export function resolveNativeConfigCompilerBinary( + opts: { readonly override?: string; readonly executablePath?: string } = {} +): string { + const override = opts.override ?? process.env.HACK_CONFIG_COMPILER_BINARY; + if (override !== undefined) { + if (!isAbsolute(override)) { + throw failure( + "E_COMPILER_PATH", + "Compiler override must be an absolute path." + ); + } + return override; + } + return join( + dirname(opts.executablePath ?? process.execPath), + "hack-config-compiler" + ); +} + +/** Read only an explicitly selected regular input, with bounded allocation. */ +export async function readNativeConfigInput(opts: { + readonly path: string; +}): Promise { + try { + const info = await stat(opts.path); + if (!info.isFile() || info.size > NATIVE_CONFIG_INPUT_LIMIT) { + throw failure( + "E_CONFIG_INPUT", + "Native configuration must be a bounded regular file." + ); + } + return await readBounded( + Bun.file(opts.path).stream(), + NATIVE_CONFIG_INPUT_LIMIT + ); + } catch (error: unknown) { + if (error instanceof NativeConfigCompilerError) { + throw error; + } + throw failure( + "E_CONFIG_INPUT", + "Cannot read the selected native configuration." + ); + } +} + +/** + * Ask the owning Rust compiler to validate and normalize authored JSON. Check the + * transport handshake before sending input. This boundary forwards no credentials, + * performs no runtime discovery, and never substitutes another compiler/backend. + */ +export async function compileNativeConfig(opts: { + readonly input: Uint8Array; + readonly binary?: string; + readonly profiles?: readonly string[]; + readonly timeoutMs?: number; + readonly signal?: AbortSignal; +}): Promise { + if (opts.input.byteLength > NATIVE_CONFIG_INPUT_LIMIT) { + throw failure( + "E_CONFIG_INPUT", + "Native configuration exceeds the input budget." + ); + } + const binary = opts.binary ?? resolveNativeConfigCompilerBinary(); + if (!isAbsolute(binary)) { + throw failure("E_COMPILER_PATH", "Compiler path must be absolute."); + } + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { + throw failure( + "E_COMPILER_BUDGET", + "Compiler timeout is outside the supported budget." + ); + } + const request = { binary, timeoutMs, signal: opts.signal }; + const handshake = await invokeCompiler({ ...request, 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 + ) { + throw failure( + "E_COMPILER_VERSION", + "Native configuration compiler version mismatch." + ); + } + const args = ["compile"]; + for (const profile of opts.profiles ?? []) { + args.push("--profile", profile); + } + const response = await invokeCompiler({ + ...request, + args, + input: opts.input, + }); + return parseCompileResponse(response); +} + +async function invokeCompiler(opts: { + readonly binary: string; + readonly args: readonly string[]; + readonly timeoutMs: number; + readonly input?: Uint8Array; + readonly signal?: AbortSignal; +}): Promise<{ readonly output: Uint8Array; readonly exitCode: number }> { + if (opts.signal?.aborted) { + throw failure( + "E_COMPILER_CANCELLED", + "Native configuration validation was cancelled." + ); + } + let child: Bun.Subprocess<"ignore", "pipe", "pipe">; + try { + child = Bun.spawn([opts.binary, ...opts.args], { + env: { PATH: "/usr/bin:/bin" }, + stdin: opts.input === undefined ? "ignore" : opts.input, + stdout: "pipe", + stderr: "pipe", + }); + } catch { + throw failure( + "E_COMPILER_MISSING", + "Native configuration compiler is unavailable. Install its matching bundle." + ); + } + let timedOut = false; + let cancelled = false; + const cancel = () => { + cancelled = true; + child.kill("SIGKILL"); + }; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, opts.timeoutMs); + opts.signal?.addEventListener("abort", cancel, { once: true }); + if (opts.signal?.aborted) { + cancel(); + } + try { + const [output, , exitCode] = await Promise.all([ + readBounded(child.stdout, OUTPUT_LIMIT), + readBounded(child.stderr, STDERR_LIMIT), + child.exited, + ]); + if (cancelled || timedOut) { + throw failure( + cancelled ? "E_COMPILER_CANCELLED" : "E_COMPILER_TIMEOUT", + cancelled + ? "Native configuration validation was cancelled." + : "Native configuration compiler timed out." + ); + } + return { output, exitCode }; + } catch (error: unknown) { + if (error instanceof NativeConfigCompilerError) { + throw error; + } + throw failure( + "E_COMPILER_RESPONSE", + "Native configuration compiler request failed." + ); + } finally { + clearTimeout(timer); + opts.signal?.removeEventListener("abort", cancel); + if (child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + } +} + +async function readBounded( + stream: ReadableStream, + limit: number +): 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 > limit) { + await reader.cancel(); + throw failure( + "E_COMPILER_BUDGET", + "Native configuration I/O exceeds its budget." + ); + } + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const output = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function parseControlJson(bytes: Uint8Array): unknown { + try { + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + } catch { + throw failure( + "E_COMPILER_RESPONSE", + "Native configuration compiler returned invalid JSON." + ); + } +} + +function parseCompileResponse(opts: { + readonly output: Uint8Array; + readonly exitCode: number; +}): NativeConfigCompileResult { + const value = parseControlJson(opts.output); + if (!isRecord(value) || value.transport_version !== 1) { + throw failure( + "E_COMPILER_RESPONSE", + "Native configuration compiler returned an invalid envelope." + ); + } + if ( + opts.exitCode === 0 && + value.ok === true && + isRecord(value.plan) && + value.plan.plan_version === 1 && + typeof value.semantic_hash === "string" && + HASH_PATTERN.test(value.semantic_hash) + ) { + return { + transport_version: 1, + ok: true, + plan: value.plan, + semantic_hash: value.semantic_hash, + }; + } + if ( + opts.exitCode === 1 && + value.ok === false && + Array.isArray(value.diagnostics) && + value.diagnostics.length > 0 + ) { + const diagnostics = value.diagnostics.map(parseDiagnostic); + return { transport_version: 1, ok: false, diagnostics }; + } + throw failure( + "E_COMPILER_RESPONSE", + "Native configuration compiler returned an invalid result." + ); +} + +function parseDiagnostic(value: unknown): NativeConfigDiagnostic { + if ( + !isRecord(value) || + typeof value.code !== "string" || + value.code.length === 0 || + typeof value.pointer !== "string" || + typeof value.message !== "string" || + typeof value.line !== "number" || + !Number.isSafeInteger(value.line) || + value.line < 1 || + typeof value.column !== "number" || + !Number.isSafeInteger(value.column) || + value.column < 1 + ) { + throw failure( + "E_COMPILER_RESPONSE", + "Native configuration compiler returned an invalid diagnostic." + ); + } + return { + code: value.code, + pointer: value.pointer, + message: value.message, + line: value.line, + column: value.column, + }; +} + +function failure(code: string, message: string): NativeConfigCompilerError { + return new NativeConfigCompilerError(code, message); +} diff --git a/tests/config-compiler-build.test.ts b/tests/config-compiler-build.test.ts new file mode 100644 index 000000000..ec6c3df1e --- /dev/null +++ b/tests/config-compiler-build.test.ts @@ -0,0 +1,33 @@ +import { afterEach, expect, test } from "bun:test"; +import { link, mkdtemp, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { verifyConfigCompilerBuildPath } from "../scripts/build-config-compiler.ts"; + +let directory = ""; +afterEach(async () => { + if (directory) { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("accepts Cargo's linked source without permitting linked output", async () => { + directory = await mkdtemp(join(tmpdir(), "hack-compiler-build-")); + const source = join(directory, "cargo-artifact"); + const linked = join(directory, "cargo-target"); + await Bun.write(source, "reviewed fixture bytes"); + await link(source, linked); + await expect(verifyConfigCompilerBuildPath({ path: linked })).rejects.toThrow( + "aliased" + ); + await verifyConfigCompilerBuildPath({ + path: linked, + allowCargoHardLink: true, + }); + expect(await Bun.file(source).text()).toBe("reviewed fixture bytes"); + const alias = join(directory, "alias"); + await symlink(source, alias); + await expect( + verifyConfigCompilerBuildPath({ path: alias, allowCargoHardLink: true }) + ).rejects.toThrow("aliased"); +}); diff --git a/tests/fixtures/prerelease-manager-mcp-v2.py b/tests/fixtures/prerelease-manager-mcp-v2.py new file mode 100644 index 000000000..6f1123d33 --- /dev/null +++ b/tests/fixtures/prerelease-manager-mcp-v2.py @@ -0,0 +1,851 @@ +#!/usr/bin/env python3 +"""Explicit, side-by-side native prerelease selection (Python 3.9+, standard library). + +Selection changes commit through one atomic receipt. Explicit manager upgrades +use a recoverable journal. Bundles and homes are retained by version; this manager +never migrates runtime state or edits shell PATH. +""" + +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import signal +import stat +import subprocess +import sys +import tarfile +import tempfile +import urllib.parse +import urllib.request +import uuid + + +VERSION = re.compile(r"5\.0\.0-next\.([1-9][0-9]*)\Z") +SHA256 = re.compile(r"[0-9a-f]{64}\Z") +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")) +EXECUTABLES = frozenset(("hack-native", "hack-relay-guest", "hack-cli", "hack-v5")) +BUNDLE_FILES = PAYLOAD | {"SHA256SUMS"} +MCP_FILES = {"adapter": "hack-mcp-adapter", "owner": "hack-mcp-owner", + "backend": "hack-mcp-backend"} +MCP_MEMBER = re.compile(r"mcp/([0-9a-f]{64})/(manifest\.json|hack-mcp-adapter|hack-mcp-owner|hack-mcp-backend)\Z") +MAX_ARCHIVE = 512 * 1024 * 1024 +METADATA_KEYS = {"schema", "version", "tag", "source_revision", "platform"} +REPOSITORY = "hack-dance/hack" +DOWNLOAD_HOSTS = {"api.github.com", "github.com", "release-assets.githubusercontent.com", + "objects.githubusercontent.com"} +# Original flat-layout manager, shipped before optional shared-MCP bundles. +# Keep this an explicit allowlist; a matching user-written receipt is not provenance. +MANAGER_PREDECESSORS = frozenset({ + "b7c49e3fec6b06790e833db1d2dcb441d2223c283b792713be46826aa2eef877", +}) +MANAGER_UPGRADE = ".manager-upgrade.json" + + +class Refusal(Exception): + """Untrusted, changed or ambiguous input: preserve the current selection.""" + + +def require(condition, message): + if not condition: + raise Refusal(message) + + +def version_number(value): + match = VERSION.fullmatch(value) if isinstance(value, str) else None + require(match is not None, "Version must be 5.0.0-next.N (positive N, no leading zero).") + return int(match.group(1)) + + +def digest(path): + value = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + value.update(chunk) + return value.hexdigest() + + +def json_bytes(value): + return (json.dumps(value, sort_keys=True, indent=2) + "\n").encode("utf-8") + + +def parse_json(raw): + def unique(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON field.") + result[key] = value + return result + try: + value = json.loads(raw, object_pairs_hook=unique) + except (ValueError, UnicodeError) as error: + raise Refusal("Malformed JSON.") from error + require(isinstance(value, dict), "Expected a JSON object.") + return value + + +def metadata(raw, version): + value = parse_json(raw) + require(set(value) == METADATA_KEYS, "Unexpected prerelease metadata fields.") + require(value["schema"] == "hack.prerelease/v1" and value["version"] == version + and value["tag"] == "v" + version and value["platform"] == "darwin-arm64" + and isinstance(value["source_revision"], str) + and REVISION.fullmatch(value["source_revision"]), "Prerelease identity mismatch.") + return value + + +def canonical(path): + path = Path(path) + require(path.is_absolute() and path == path.resolve(), + "Use a canonical absolute path without symlinks or traversal.") + return path + + +def owned(path, directory=False, mode=None): + info = path.lstat() + expected = stat.S_ISDIR(info.st_mode) if directory else stat.S_ISREG(info.st_mode) + require(expected and info.st_uid == os.getuid(), "Foreign or aliased path: " + str(path)) + if not directory: + require(info.st_nlink == 1, "Hard-linked file: " + str(path)) + if mode is not None: + require(stat.S_IMODE(info.st_mode) == mode, "Unsafe mode: " + str(path)) + return info + + +def private_json(path): + owned(path, mode=0o600) + require(path.stat().st_size <= 1024 * 1024, "Oversized installation receipt.") + return parse_json(path.read_bytes()) + + +def sync_directory(path): + descriptor = os.open(str(path), os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_file(path, contents, mode=0o600): + descriptor = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) + with os.fdopen(descriptor, "wb") as target: + os.fchmod(target.fileno(), mode) + target.write(contents) + target.flush() + os.fsync(target.fileno()) + + +def atomic_file(path, contents): + temporary = path.parent / (".selection-" + uuid.uuid4().hex) + try: + write_file(temporary, contents) + os.replace(str(temporary), str(path)) + sync_directory(path.parent) + finally: + if temporary.exists(): + temporary.unlink() + + +def atomic_json(path, value): + atomic_file(path, json_bytes(value)) + + +def launcher_bytes(): + return ("#!/bin/sh\nset -eu\nroot=$(CDPATH= cd -- \"$(dirname -- \"$0\")/..\" && pwd -P)\n" + "exec /usr/bin/python3 -I -S \"$root/manager.py\" --root \"$root\" run -- \"$@\"\n").encode() + + +def checksums(raw, expected): + try: + lines = raw.decode("ascii").splitlines() + except UnicodeError as error: + raise Refusal("Malformed SHA256SUMS.") from error + result = {} + for line in lines: + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9./-]+)", line) + require(match is not None, "Malformed SHA256SUMS entry.") + checksum, name = match.groups() + require(name in expected and name not in result, "Duplicate or foreign checksum entry.") + result[name] = checksum + require(set(result) == set(expected), "Incomplete checksum manifest.") + return result + + +def payload_inventory(names): + """Accept the original flat payload or one complete content-addressed MCP bundle.""" + names = set(names) + require(BUNDLE_FILES <= names, "Incomplete candidate bundle.") + extra = names - BUNDLE_FILES + if extra: + matches = [MCP_MEMBER.fullmatch(name) for name in extra] + require(all(matches), "Foreign candidate MCP payload.") + identities = {match.group(1) for match in matches} + require(len(identities) == 1, "Candidate requires one MCP bundle identity.") + prefix = "mcp/" + next(iter(identities)) + "/" + require(extra == {prefix + name for name in (*MCP_FILES.values(), "manifest.json")}, + "Incomplete candidate MCP payload.") + return names - {"SHA256SUMS"} + + +def bundle_inventory(bundle): + names = {entry.name for entry in bundle.iterdir()} + if "mcp" in names: + owned(bundle / "mcp", directory=True, mode=0o700) + identities = list((bundle / "mcp").iterdir()) + require(len(identities) == 1 and SHA256.fullmatch(identities[0].name), + "Candidate requires one MCP bundle identity.") + directory = identities[0] + owned(directory, directory=True, mode=0o700) + names.remove("mcp") + names.update("mcp/" + directory.name + "/" + entry.name for entry in directory.iterdir()) + return payload_inventory(names) + + +def payload_mode(name): + if MCP_MEMBER.fullmatch(name): + return 0o400 if name.endswith("/manifest.json") else 0o500 + return 0o755 if name in EXECUTABLES else 0o600 + + +def verify_mcp_bundle(bundle, payload): + nested = sorted(set(payload) - PAYLOAD) + if not nested: + return + prefix = str(Path(nested[0]).parent) + path = bundle / prefix / "manifest.json" + require(path.stat().st_size <= 8192, "Oversized MCP manifest.") + manifest = parse_json(path.read_bytes()) + require(set(manifest) == {"schemaVersion", "startupProtocol", "wireProtocol", "platform", + "architecture", "files", "bundleId"}, "Invalid MCP manifest fields.") + require(all(type(manifest[field]) in (int, float) + for field in ("schemaVersion", "startupProtocol", "wireProtocol")) + and manifest["schemaVersion"] == 1 and manifest["startupProtocol"] == 2 + and manifest["wireProtocol"] == 1 and manifest["platform"] == "darwin" + and manifest["architecture"] == "arm64", "Incompatible MCP protocol or host.") + files = manifest["files"] + require(isinstance(files, dict) and set(files) == set(MCP_FILES), "Invalid MCP asset inventory.") + canonical_files = {} + for role, name in MCP_FILES.items(): + entry = files[role] + require(isinstance(entry, dict) and set(entry) == {"sha256", "bytes"} + and isinstance(entry["sha256"], str) and SHA256.fullmatch(entry["sha256"]) + and type(entry["bytes"]) is int and 0 < entry["bytes"] <= 256 * 1024 * 1024, + "Invalid MCP asset fingerprint.") + asset = bundle / prefix / name + require(asset.stat().st_size == entry["bytes"] and digest(asset) == entry["sha256"], + "MCP asset fingerprint mismatch.") + canonical_files[role] = {"sha256": entry["sha256"], "bytes": entry["bytes"]} + body = {"schemaVersion": 1, "startupProtocol": 2, "wireProtocol": 1, + "platform": "darwin", "architecture": "arm64", "files": canonical_files} + identity = hashlib.sha256(json.dumps(body, separators=(",", ":")).encode()).hexdigest() + require(manifest["bundleId"] == identity and Path(prefix).name == identity, + "MCP bundle identity mismatch.") + + +def verify_bundle(bundle, version): + owned(bundle, directory=True, mode=0o700) + payload = bundle_inventory(bundle) + for name in payload | {"SHA256SUMS"}: + owned(bundle / name, mode=payload_mode(name)) + require((bundle / "SHA256SUMS").stat().st_size <= 64 * 1024 + and (bundle / "prerelease.json").stat().st_size <= 64 * 1024, + "Oversized bundle metadata or checksum manifest.") + manifest = checksums((bundle / "SHA256SUMS").read_bytes(), payload) + for name, checksum in manifest.items(): + require(digest(bundle / name) == checksum, "Candidate checksum mismatch: " + name) + identity = metadata((bundle / "prerelease.json").read_bytes(), version) + verify_mcp_bundle(bundle, payload) + return identity, manifest + + +def verify_signatures(bundle): + names = ["hack-native", "hack-cli"] + names.extend(name for name in bundle_inventory(bundle) - PAYLOAD if not name.endswith("/manifest.json")) + for name in names: + try: + result = subprocess.run(["/usr/bin/codesign", "--verify", "--strict", str(bundle / name)], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refusal("Cannot verify candidate code signature.") from error + require(result.returncode == 0, "Candidate code signature failed: " + name) + + +def extract_archive(archive, checksum, destination, version, release_metadata=None): + owned(archive) + require(SHA256.fullmatch(checksum) is not None, "Expected archive SHA-256 is required.") + require(archive.stat().st_size <= MAX_ARCHIVE and digest(archive) == checksum, + "Archive checksum or size mismatch.") + try: + with tarfile.open(str(archive), "r:gz") as source: + entries = [] + seen = set() + total = 0 + for entry in source: + require(len(entries) < len(BUNDLE_FILES) + 4, "Incomplete or duplicate archive entries.") + require((entry.name in BUNDLE_FILES or MCP_MEMBER.fullmatch(entry.name)) and entry.name not in seen, + "Foreign, duplicate or traversing archive entry.") + require(entry.isfile() and not entry.issparse() and entry.size > 0 + and entry.size <= MAX_ARCHIVE, "Only bounded regular archive files are accepted.") + seen.add(entry.name) + total += entry.size + require(total <= MAX_ARCHIVE, "Oversized archive.") + entries.append(entry) + payload = payload_inventory(seen) + by_name = {entry.name: entry for entry in entries} + require(by_name["prerelease.json"].size <= 64 * 1024 + and by_name["SHA256SUMS"].size <= 64 * 1024, + "Oversized archive metadata or checksum manifest.") + with source.extractfile(by_name["prerelease.json"]) as incoming: + identity = metadata(incoming.read(), version) + require(release_metadata is None or identity == release_metadata, + "Archive metadata differs from the pinned release.") + with source.extractfile(by_name["SHA256SUMS"]) as incoming: + manifest = checksums(incoming.read(), payload) + # Preflight hashes before writing any payload. Revalidation after + # copying also catches a source changed between these two passes. + for entry in entries: + name = entry.name + if name not in payload: + continue + value = hashlib.sha256() + with source.extractfile(entry) as incoming: + for chunk in iter(lambda: incoming.read(1024 * 1024), b""): + value.update(chunk) + require(value.hexdigest() == manifest[name], "Candidate checksum mismatch: " + name) + destination.mkdir(mode=0o700) + nested = payload - PAYLOAD + if nested: + prefix = Path(next(iter(nested))).parent + (destination / "mcp").mkdir(mode=0o700) + (destination / prefix).mkdir(mode=0o700) + for entry in entries: + incoming = source.extractfile(entry) + require(incoming is not None, "Unreadable archive payload.") + with incoming: + descriptor = os.open(str(destination / entry.name), + os.O_WRONLY | os.O_CREAT | os.O_EXCL, + payload_mode(entry.name)) + with os.fdopen(descriptor, "wb") as target: + os.fchmod(target.fileno(), payload_mode(entry.name)) + remaining = entry.size + while remaining: + chunk = incoming.read(min(1024 * 1024, remaining)) + require(bool(chunk), "Truncated archive payload.") + target.write(chunk) + remaining -= len(chunk) + target.flush() + os.fsync(target.fileno()) + except (tarfile.TarError, EOFError) as error: + raise Refusal("Invalid candidate archive.") from error + identity, manifest = verify_bundle(destination, version) + require(release_metadata is None or identity == release_metadata, + "Archive metadata differs from the pinned release.") + verify_signatures(destination) + sync_directory(destination) + return identity, manifest + + +def secure_url(url): + parsed = urllib.parse.urlsplit(url) + require(parsed.scheme == "https" and parsed.hostname in DOWNLOAD_HOSTS + and parsed.port in (None, 443) and not parsed.username and not parsed.password + and not parsed.fragment, "Refusing nonofficial or non-HTTPS download.") + + +class OfficialRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, url): + secure_url(url) + return super().redirect_request(request, response, code, message, headers, url) + + +def download(url, target, limit): + secure_url(url) + request = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json", + "User-Agent": "hack-prerelease-installer"}) + opener = urllib.request.build_opener(OfficialRedirect()) + with opener.open(request, timeout=30) as response: + secure_url(response.geturl()) + descriptor = os.open(str(target), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(descriptor, "wb") as output: + size = 0 + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + size += len(chunk) + require(size <= limit, "Release download exceeds its size budget.") + output.write(chunk) + output.flush() + os.fsync(output.fileno()) + + +def official_archive(stage, version): + tag = "v" + version + api_path = stage / "release-api.json" + download("https://api.github.com/repos/" + REPOSITORY + "/releases/tags/" + tag, + api_path, 2 * 1024 * 1024) + release = parse_json(api_path.read_bytes()) + require(release.get("tag_name") == tag and release.get("prerelease") is True + and release.get("draft") is False, "Expected a published, pinned official prerelease.") + archive_name = "hack-" + version + "-darwin-arm64-native.tar.gz" + wanted = {archive_name, "SHA256SUMS", "prerelease.json"} + assets = release.get("assets") + require(isinstance(assets, list), "Missing release assets.") + selected = {} + for asset in assets: + require(isinstance(asset, dict), "Malformed release asset.") + name = asset.get("name") + require(isinstance(name, str), "Malformed release asset name.") + if name in wanted: + require(name not in selected, "Duplicate release asset.") + expected = "https://github.com/" + REPOSITORY + "/releases/download/" + tag + "/" + name + require(asset.get("browser_download_url") == expected, "Foreign release asset URL.") + selected[name] = expected + require(set(selected) == wanted, "Incomplete official prerelease assets.") + download(selected["prerelease.json"], stage / "release-metadata.json", 64 * 1024) + identity = metadata((stage / "release-metadata.json").read_bytes(), version) + download("https://api.github.com/repos/" + REPOSITORY + "/git/ref/tags/" + tag, + stage / "release-tag.json", 64 * 1024) + reference = parse_json((stage / "release-tag.json").read_bytes()) + commit = reference.get("object") + require(reference.get("ref") == "refs/tags/" + tag and isinstance(commit, dict) + and commit.get("type") == "commit" and commit.get("sha") == identity["source_revision"], + "Official prerelease tag does not match its source revision.") + download(selected["SHA256SUMS"], stage / "release-checksums", 64 * 1024) + checksum = checksums((stage / "release-checksums").read_bytes(), {archive_name})[archive_name] + archive = stage / archive_name + download(selected[archive_name], archive, MAX_ARCHIVE) + return archive, checksum, identity + + +class Channel: + """Owned private layout; the selection file records immutable receipt hashes.""" + + def __init__(self, root): + self.root = canonical(root) + self.state = None + + def initialize(self): + if not self.root.exists(): + owned(self.root.parent, directory=True) + self.root.mkdir(mode=0o700) + owned(self.root, directory=True, mode=0o700) + if (self.root / ".channel.json").exists(): + return + require(not list(self.root.iterdir()), "Refusing to adopt a nonempty installation root.") + (self.root / "bin").mkdir(mode=0o700) + (self.root / "versions").mkdir(mode=0o700) + write_file(self.root / "manager.py", Path(__file__).read_bytes()) + write_file(self.root / "bin/hack-next", launcher_bytes(), 0o755) + root_info = self.root.stat() + write_file(self.root / ".channel.json", json_bytes({ + "schema": "hack.prerelease-install/v1", "root": str(self.root), "uid": os.getuid(), + "device": root_info.st_dev, "inode": root_info.st_ino, + "manager_sha256": digest(self.root / "manager.py"), + "launcher_sha256": digest(self.root / "bin/hack-next")})) + write_file(self.root / ".selection.json", json_bytes({ + "schema": "hack.prerelease-selection/v1", "installed": {}, + "selected": None, "previous": None})) + write_file(self.root / ".manager.lock", b"") + sync_directory(self.root) + + @contextlib.contextmanager + def lock(self, shared=False, manager_upgrade=False): + owned(self.root, directory=True, mode=0o700) + owned(self.root / ".manager.lock", mode=0o600) + descriptor = os.open(str(self.root / ".manager.lock"), os.O_RDWR | os.O_NOFOLLOW) + with os.fdopen(descriptor, "rb") as handle: + try: + fcntl.flock(handle, (fcntl.LOCK_SH if shared else fcntl.LOCK_EX) | fcntl.LOCK_NB) + except BlockingIOError as error: + raise Refusal("Another candidate manager or launcher is active.") from error + self.validate(manager_upgrade=manager_upgrade) + yield + + def validate(self, manager_upgrade=False): + root_info = owned(self.root, directory=True, mode=0o700) + pending = os.path.lexists(self.root / MANAGER_UPGRADE) + require(not pending or manager_upgrade, + "Manager upgrade is pending; rerun upgrade-manager with the same reviewed installer.") + transition = self.read_manager_upgrade() if pending else None + marker = private_json(self.root / ".channel.json") + require(set(marker) == {"schema", "root", "uid", "device", "inode", + "manager_sha256", "launcher_sha256"} + and marker["schema"] == "hack.prerelease-install/v1" + and marker["root"] == str(self.root) and marker["uid"] == os.getuid() + and marker["device"] == root_info.st_dev and marker["inode"] == root_info.st_ino, + "Foreign or malformed installation receipt.") + owned(self.root / "manager.py", mode=0o600) + owned(self.root / "bin", directory=True, mode=0o700) + owned(self.root / "bin/hack-next", mode=0o755) + manager_hash = digest(self.root / "manager.py") + require((manager_hash == marker["manager_sha256"] or transition is not None + and manager_hash == transition["to_sha256"] + and marker["manager_sha256"] == transition["from_sha256"]) + and digest(self.root / "bin/hack-next") == marker["launcher_sha256"], + "Installation manager or launcher changed.") + require({p.name for p in (self.root / "bin").iterdir()} == {"hack-next"}, + "Foreign launcher entry.") + state = private_json(self.root / ".selection.json") + require(set(state) == {"schema", "installed", "selected", "previous"} + and state["schema"] == "hack.prerelease-selection/v1" + and isinstance(state["installed"], dict), "Malformed selection receipt.") + installed = state["installed"] + require(len(installed) <= 256, "Too many candidate versions; inspection required.") + for version, checksum in installed.items(): + version_number(version) + require(isinstance(checksum, str) and SHA256.fullmatch(checksum), "Malformed receipt hash.") + self.verify_version(version, checksum) + for key in ("selected", "previous"): + require(state[key] is None or isinstance(state[key], str) and state[key] in installed, + "Unknown selected or previous candidate.") + require(state["selected"] is None or state["selected"] != state["previous"], + "Malformed candidate history.") + owned(self.root / "versions", directory=True, mode=0o700) + for entry in (self.root / "versions").iterdir(): + if entry.name not in installed: + version_number(entry.name) + # Rename can precede selection by a power-loss boundary. Validate + # the retained directory, but never select/adopt it automatically. + private_json(entry / ".receipt.json") + self.verify_version(entry.name, digest(entry / ".receipt.json")) + allowed = {".channel.json", ".selection.json", ".manager.lock", "manager.py", "bin", "versions"} + if transition is not None: + allowed.add(MANAGER_UPGRADE) + for entry in self.root.iterdir(): + if entry.name in allowed: + continue + if re.fullmatch(r"\.stage-[0-9a-f]{32}", entry.name): + owned(entry, directory=True, mode=0o700) + continue + if re.fullmatch(r"\.selection-[0-9a-f]{32}", entry.name): + owned(entry, mode=0o600) + continue + raise Refusal("Foreign installation entry: " + entry.name) + self.state = state + + def read_manager_upgrade(self): + """Verify both sides of the only supported two-file transition before recovery. + + The journal closes ordinary launch admission before either file changes. + Recovery may finish this exact transition, never adopt changed receipts or + select software. The staged originals remain available for inspection. + """ + plan = private_json(self.root / MANAGER_UPGRADE) + require(set(plan) == {"schema", "stage", "from_sha256", "to_sha256", + "channel_sha256", "selection_sha256"} + and plan["schema"] == "hack.prerelease-manager-upgrade/v1" + and isinstance(plan["stage"], str) + and re.fullmatch(r"\.stage-[0-9a-f]{32}", plan["stage"]), + "Malformed manager upgrade journal.") + require(all(isinstance(plan[key], str) and SHA256.fullmatch(plan[key]) + for key in ("from_sha256", "to_sha256", "channel_sha256", "selection_sha256")), + "Malformed manager upgrade digest.") + require(plan["from_sha256"] in MANAGER_PREDECESSORS + and plan["to_sha256"] == digest(Path(__file__)), + "Manager recovery requires the same reviewed installer and a known predecessor.") + stage = self.root / plan["stage"] + owned(stage, directory=True, mode=0o700) + require({p.name for p in stage.iterdir()} == { + "manager-before.py", "manager-after.py", "channel-before.json", "channel-after.json"}, + "Foreign manager upgrade staging entry.") + for entry in stage.iterdir(): + owned(entry, mode=0o600) + before = private_json(stage / "channel-before.json") + after = private_json(stage / "channel-after.json") + require(digest(stage / "manager-before.py") == plan["from_sha256"] + and digest(stage / "manager-after.py") == plan["to_sha256"] + and digest(stage / "channel-before.json") == plan["channel_sha256"] + and before.get("manager_sha256") == plan["from_sha256"] + and after == dict(before, manager_sha256=plan["to_sha256"]), + "Manager upgrade staging changed.") + owned(self.root / "manager.py", mode=0o600) + private_json(self.root / ".channel.json") + private_json(self.root / ".selection.json") + old_receipt = (stage / "channel-before.json").read_bytes() + new_receipt = (stage / "channel-after.json").read_bytes() + current_receipt = (self.root / ".channel.json").read_bytes() + current_hash = digest(self.root / "manager.py") + require((current_hash == plan["from_sha256"] and current_receipt == old_receipt) + or (current_hash == plan["to_sha256"] and current_receipt in (old_receipt, new_receipt)), + "Manager upgrade publication state changed.") + require(digest(self.root / ".selection.json") == plan["selection_sha256"], + "Selection changed during manager upgrade; inspection required.") + owned(self.root / "bin", directory=True, mode=0o700) + owned(self.root / "bin/hack-next", mode=0o755) + require((self.root / "bin/hack-next").read_bytes() == launcher_bytes(), + "Custom launcher cannot be upgraded.") + return plan + + def upgrade_manager(self): + """Explicitly replace a known manager, with recoverable fail-closed publication.""" + self.validate(manager_upgrade=True) + pending = os.path.lexists(self.root / MANAGER_UPGRADE) + source = canonical(Path(__file__).absolute()) + owned(source) + target = source.read_bytes() + target_hash = hashlib.sha256(target).hexdigest() + if not pending: + current_hash = digest(self.root / "manager.py") + if current_hash == target_hash: + return + require(current_hash in MANAGER_PREDECESSORS, + "Unknown or customized manager; explicit upgrade supports only the reviewed flat-layout predecessor.") + require((self.root / "bin/hack-next").read_bytes() == launcher_bytes(), + "Custom launcher cannot be upgraded.") + # All retained versions share this manager. Keep the existing executor + # ownership checks, including status/down/status, for each of them. + for version in self.state["installed"]: + self.require_quiescent(version, manager_upgrade=True) + self.validate(manager_upgrade=True) + if not pending: + stage = self.root / (".stage-" + uuid.uuid4().hex) + stage.mkdir(mode=0o700) + before = (self.root / ".channel.json").read_bytes() + after = dict(parse_json(before), manager_sha256=target_hash) + write_file(stage / "manager-before.py", (self.root / "manager.py").read_bytes()) + write_file(stage / "manager-after.py", target) + write_file(stage / "channel-before.json", before) + write_file(stage / "channel-after.json", json_bytes(after)) + plan = {"schema": "hack.prerelease-manager-upgrade/v1", "stage": stage.name, + "from_sha256": current_hash, "to_sha256": target_hash, + "channel_sha256": hashlib.sha256(before).hexdigest(), + "selection_sha256": digest(self.root / ".selection.json")} + write_file(stage / "journal.json", json_bytes(plan)) + sync_directory(stage) + self.validate() + os.rename(str(stage / "journal.json"), str(self.root / MANAGER_UPGRADE)) + sync_directory(stage) + sync_directory(self.root) + self.validate(manager_upgrade=True) + plan = self.read_manager_upgrade() + stage = self.root / plan["stage"] + if digest(self.root / "manager.py") != target_hash: + atomic_file(self.root / "manager.py", (stage / "manager-after.py").read_bytes()) + self.validate(manager_upgrade=True) + if (self.root / ".channel.json").read_bytes() != (stage / "channel-after.json").read_bytes(): + atomic_file(self.root / ".channel.json", (stage / "channel-after.json").read_bytes()) + self.validate(manager_upgrade=True) + (self.root / MANAGER_UPGRADE).unlink() + sync_directory(self.root) + self.validate() + + def verify_version(self, version, checksum): + directory = self.root / "versions" / version + owned(directory, directory=True, mode=0o700) + require({p.name for p in directory.iterdir()} == {"bundle", "native-home", "cli-home", ".receipt.json"}, + "Incomplete or foreign version installation.") + receipt_path = directory / ".receipt.json" + receipt = private_json(receipt_path) + require(digest(receipt_path) == checksum, "Candidate installation receipt changed.") + require(set(receipt) == {"schema", "metadata", "archive_sha256", "checksums", "homes"} + and receipt["schema"] == "hack.prerelease-version/v1" + and isinstance(receipt["archive_sha256"], str) + and SHA256.fullmatch(receipt["archive_sha256"]), "Malformed version receipt.") + identity, manifest = verify_bundle(directory / "bundle", version) + require(receipt["metadata"] == identity and receipt["checksums"] == manifest, + "Candidate identity or checksum receipt changed.") + require(isinstance(receipt["homes"], dict) and set(receipt["homes"]) == {"native-home", "cli-home"}, + "Malformed candidate home receipt.") + for name in ("native-home", "cli-home"): + info = owned(directory / name, directory=True, mode=0o700) + require(receipt["homes"][name] == {"device": info.st_dev, "inode": info.st_ino}, + "Candidate home identity changed.") + return directory + + def environment(self, version): + directory = self.root / "versions" / version + environment = dict(os.environ) + environment.pop("HACK_GLOBAL_CONFIG_PATH", None) + environment.update({"HACK_RUNTIME_BACKEND": "native", + "HACK_NATIVE_BINARY": str(directory / "bundle/hack-native"), + "HACK_NATIVE_HOME": str(directory / "native-home"), + "HACK_HOME": str(directory / "cli-home")}) + return environment + + def require_quiescent(self, version, manager_upgrade=False): + if version is None: + return + self.validate(manager_upgrade=manager_upgrade) + directory = self.verify_version(version, self.state["installed"][version]) + verify_signatures(directory / "bundle") + for action in ("status", "down", "status"): + command = [str(directory / "bundle/hack-native"), "--candidate-root", + str(directory / "native-home"), "runtime", action, "--json"] + try: + result = subprocess.run(command, env=self.environment(version), stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + timeout=30, check=False) + except (OSError, subprocess.TimeoutExpired) as error: + raise Refusal("Candidate quiescence is unavailable; selection preserved.") from error + require(result.returncode == 0 and len(result.stdout) <= 1024 * 1024, + "Candidate quiescence check failed; failure never means idle.") + status = parse_json(result.stdout) + phase = status.get("phase") + require(phase in ("uninitialized", "stopped") and status.get("process_alive") is False, + "Candidate runtime is active or ambiguous; stop it through its supported CLI first.") + if phase == "uninitialized": + require(not list((directory / "native-home").iterdir()), + "Uninitialized home contains state; inspect it through its owning CLI.") + self.validate(manager_upgrade=manager_upgrade) + + def select(self, version, installed=None): + self.require_quiescent(self.state["selected"]) + if version is not None: + self.require_quiescent(version) + # Recheck receipts at the effect boundary, then commit exactly one pointer. + self.validate() + state = dict(self.state) + if installed is not None: + state["installed"] = installed + if version != state["selected"]: + state["previous"] = state["selected"] + state["selected"] = version + atomic_json(self.root / ".selection.json", state) + self.state = state + + def install(self, version, archive=None, checksum=None, upgrade=False): + version_number(version) + current = self.state["selected"] + if version in self.state["installed"]: + require(version == current, "Version is already installed; use rollback to select it.") + if archive is not None: + archive = canonical(archive) + owned(archive) + require(archive is None or digest(archive) == checksum + == private_json(self.root / "versions" / version / ".receipt.json")["archive_sha256"], + "Installed version differs from the supplied archive.") + return + require(upgrade or current is None, "A candidate is selected; use upgrade with a newer version.") + require(current is None or version_number(version) > version_number(current), + "Upgrade must increase the prerelease version; use rollback for an installed version.") + self.require_quiescent(current) + stage = self.root / (".stage-" + uuid.uuid4().hex) + stage.mkdir(mode=0o700) + if archive is None: + archive, checksum, release_metadata = official_archive(stage, version) + else: + archive = canonical(archive) + release_metadata = None + installation = stage / "installation" + installation.mkdir(mode=0o700) + identity, manifest = extract_archive(archive, checksum, installation / "bundle", version, + release_metadata) + # The retained launcher always executes its recorded manager. Never select + # a new layout that an older manager cannot subsequently validate/rollback. + # Manager replacement is a separate explicit, recoverable operation. + require(set(manifest) <= PAYLOAD + or digest(self.root / "manager.py") == digest(Path(__file__)), + "Shared MCP requires this channel's retained manager to match the installer. " + "Run upgrade-manager with this reviewed installer, or use a fresh --root; " + "the existing channel and its selection are unchanged.") + homes = {} + for name in ("native-home", "cli-home"): + (installation / name).mkdir(mode=0o700) + info = (installation / name).stat() + homes[name] = {"device": info.st_dev, "inode": info.st_ino} + write_file(installation / ".receipt.json", json_bytes({ + "schema": "hack.prerelease-version/v1", "metadata": identity, + "archive_sha256": checksum, "checksums": manifest, "homes": homes})) + sync_directory(installation) + # Final refusal happens before publication. A power loss after rename retains + # an uncommitted directory for inspection, while the old selection stays valid. + self.require_quiescent(current) + self.validate() + destination = self.root / "versions" / version + require(not destination.exists(), "Refusing to overwrite a candidate version.") + os.rename(str(installation), str(destination)) + sync_directory(destination.parent) + installed = dict(self.state["installed"]) + installed[version] = digest(destination / ".receipt.json") + state = dict(self.state, installed=installed, selected=version, previous=current) + atomic_json(self.root / ".selection.json", state) + self.state = state + + def run(self, arguments): + version = self.state["selected"] + require(version is not None, "No candidate selected. Use your ordinary stable hack command.") + directory = self.verify_version(version, self.state["installed"][version]) + verify_signatures(directory / "bundle") + # Shared launcher locks permit ps/down alongside foreground up, while an + # exclusive selection switch refuses any in-flight launcher. Background + # runtime state is gated by the owning executor on later switches. + child = subprocess.Popen([str(directory / "bundle/hack-v5"), *arguments], + env=self.environment(version)) + handlers = {} + def forward(signum, _frame): + child.send_signal(signum) + try: + for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + handlers[signum] = signal.signal(signum, forward) + code = child.wait() + return code if code >= 0 else 128 - code + finally: + for signum, handler in handlers.items(): + signal.signal(signum, handler) + + +def parser(): + arguments = argparse.ArgumentParser(description=__doc__) + arguments.add_argument("--root", type=Path, default=Path.home() / ".hack-next", + help="private canonical installation root (default ~/.hack-next)") + commands = arguments.add_subparsers(dest="command", required=True) + for command in ("install", "upgrade"): + install = commands.add_parser(command) + install.add_argument("--version", required=True) + install.add_argument("--archive", type=Path, help="explicit local archive instead of GitHub") + install.add_argument("--sha256", help="required expected digest for --archive") + rollback = commands.add_parser("rollback") + rollback.add_argument("--version", help="retained version; default previous selection") + commands.add_parser("stable", help="deselect candidate; retain all bundles and homes") + commands.add_parser("status") + commands.add_parser("upgrade-manager", help="explicitly upgrade a known channel manager, or finish its interrupted upgrade") + run = commands.add_parser("run") + run.add_argument("arguments", nargs=argparse.REMAINDER) + return arguments + + +def main(argv=None): + args = parser().parse_args(argv) + os.umask(0o077) + require(platform.system() == "Darwin" and platform.machine() == "arm64", + "Native prereleases support Apple Silicon macOS only.") + channel = Channel(args.root) + if args.command in ("install", "upgrade"): + version_number(args.version) + require(bool(args.archive) == bool(args.sha256), "--archive and --sha256 must be supplied together.") + channel.initialize() + with channel.lock(shared=args.command == "run", manager_upgrade=args.command == "upgrade-manager"): + if args.command in ("install", "upgrade"): + channel.install(args.version, args.archive, args.sha256, args.command == "upgrade") + elif args.command == "upgrade-manager": + channel.upgrade_manager() + elif args.command == "rollback": + version = args.version or channel.state["previous"] + require(version is not None and version in channel.state["installed"], "No retained rollback version.") + channel.select(version) + elif args.command == "stable": + channel.select(None) + elif args.command == "run": + arguments = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments + return channel.run(arguments) + print(json.dumps({"selected": channel.state["selected"], "previous": channel.state["previous"], + "installed": sorted(channel.state["installed"], key=version_number), + "launcher": str(channel.root / "bin/hack-next")})) + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except (Refusal, OSError, ValueError) as error: + print("hack-next: " + str(error), file=sys.stderr) + sys.exit(1) diff --git a/tests/native-config-command.test.ts b/tests/native-config-command.test.ts new file mode 100644 index 000000000..37577f0a2 --- /dev/null +++ b/tests/native-config-command.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +let directory = ""; +const root = resolve(import.meta.dir, ".."); +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "hack-native-config-command-")); +}); +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function startCli(opts: { compilerBody: string; json?: boolean }) { + const compiler = join(directory, "compiler"); + const file = join(directory, "input.json"); + await Bun.write(file, '{"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} }` + ); + await chmod(compiler, 0o755); + return Bun.spawn( + [ + process.execPath, + join(root, "index.ts"), + "config", + "validate", + "--file", + file, + ...(opts.json ? ["--json"] : []), + ], + { + cwd: directory, + env: { + PATH: "/usr/bin:/bin", + HOME: directory, + HACK_LOGGER: "console", + HACK_CONFIG_COMPILER_BINARY: compiler, + }, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + } + ); +} + +test("human diagnostics escape authored control characters", async () => { + const response = { + transport_version: 1, + ok: false, + diagnostics: [ + { + code: "invalid_name", + pointer: "/services/\u001b[2J\nforged", + message: "Invalid name.", + line: 1, + column: 1, + }, + ], + }; + const child = await startCli({ + compilerBody: `console.log(${JSON.stringify(JSON.stringify(response))}); process.exitCode = 1;`, + }); + const [stderr, exit] = await Promise.all([ + new Response(child.stderr).text(), + child.exited, + ]); + expect(exit).toBe(1); + expect(stderr).toContain('"/services/\\u001b[2J\\nforged"'); + expect(stderr).not.toContain("\u001b[2J"); + expect(stderr).not.toContain("\nforged"); +}); + +test.each([ + "SIGINT", + "SIGTERM", +] as const)("CLI %s reaps its owned compiler", async (signal) => { + const pidPath = join(directory, "pid"); + const child = await startCli({ + json: true, + compilerBody: `await Bun.write(${JSON.stringify(pidPath)}, String(process.pid)); await Bun.sleep(10000);`, + }); + try { + const deadline = Date.now() + 4000; + while (!(await Bun.file(pidPath).exists()) && Date.now() < deadline) { + await Bun.sleep(20); + } + expect(await Bun.file(pidPath).exists()).toBe(true); + const compilerPid = Number(await Bun.file(pidPath).text()); + child.kill(signal); + const [stdout, exit] = await Promise.all([ + new Response(child.stdout).text(), + child.exited, + ]); + expect(exit).toBe(1); + expect(JSON.parse(stdout).error.code).toBe("E_COMPILER_CANCELLED"); + expect(() => process.kill(compilerPid, 0)).toThrow(); + } finally { + if (child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + } +}); diff --git a/tests/native-config-compiler.test.ts b/tests/native-config-compiler.test.ts new file mode 100644 index 000000000..8a2ed23a2 --- /dev/null +++ b/tests/native-config-compiler.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + compileNativeConfig, + NATIVE_CONFIG_INPUT_LIMIT, + readNativeConfigInput, + resolveNativeConfigCompilerBinary, +} from "../src/lib/native-config-compiler.ts"; +import { restoreEnv } from "./helpers/env.ts"; + +const PROTOCOL = { transport_version: 1, authored_version: 1, plan_version: 1 }; +const SUCCESS = { + transport_version: 1, + ok: true, + plan: { plan_version: 1 }, + semantic_hash: "a".repeat(64), +}; +const INPUT = new TextEncoder().encode('{"schema_version":1,"name":"fixture"}'); +let directory = ""; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "hack-compiler-transport-")); +}); +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +async function fixture(body: string): Promise { + const path = join(directory, "compiler"); + await Bun.write(path, `#!${process.execPath}\n${body}\n`); + await chmod(path, 0o755); + return path; +} + +function script(compile: string): string { + return `if (process.argv[2] === '--protocol') { process.stdout.write(${JSON.stringify(JSON.stringify(PROTOCOL))}); } else { ${compile} }`; +} + +test("uses adjacent bundle compiler without PATH lookup", () => { + expect( + resolveNativeConfigCompilerBinary({ override: "/fixture/compiler" }) + ).toBe("/fixture/compiler"); + expect(() => + resolveNativeConfigCompilerBinary({ override: "compiler" }) + ).toThrow("absolute path"); + const original = process.env.HACK_CONFIG_COMPILER_BINARY; + Reflect.deleteProperty(process.env, "HACK_CONFIG_COMPILER_BINARY"); + try { + expect( + resolveNativeConfigCompilerBinary({ executablePath: "/bundle/hack-cli" }) + ).toBe("/bundle/hack-config-compiler"); + } finally { + restoreEnv("HACK_CONFIG_COMPILER_BINARY", original); + } +}); + +test("missing compiler fails with fixed actionable output", async () => { + await expect( + compileNativeConfig({ input: INPUT, binary: join(directory, "absent") }) + ).rejects.toThrow("Install its matching bundle"); +}); + +test("checks version before sending authored input", async () => { + const receipt = join(directory, "compile-was-called"); + const binary = await fixture( + `if (process.argv[2] === '--protocol') { console.log('{"transport_version":99}'); } else { await Bun.write(${JSON.stringify(receipt)}, await Bun.stdin.text()); }` + ); + await expect(compileNativeConfig({ input: INPUT, binary })).rejects.toThrow( + "version mismatch" + ); + expect(await Bun.file(receipt).exists()).toBe(false); +}); + +test("compiler receives no inherited credentials and forwards profile arguments exactly", async () => { + const original = process.env.AWS_SECRET_ACCESS_KEY; + process.env.AWS_SECRET_ACCESS_KEY = "compiler-test-do-not-forward"; + try { + const binary = await fixture( + script( + `const value = ${JSON.stringify(SUCCESS)}; value.plan.received = await Bun.stdin.text(); value.plan.profiles = process.argv.slice(3); value.plan.keys = Object.keys(process.env).sort(); console.log(JSON.stringify(value));` + ) + ); + const result = await compileNativeConfig({ + input: INPUT, + binary, + profiles: ["with spaces", "--unsafe"], + }); + expect(result.ok).toBe(true); + if (!result.ok) { + throw new Error("Expected transport success"); + } + expect(result.plan.received).toBe(new TextDecoder().decode(INPUT)); + expect(result.plan.profiles).toEqual([ + "--profile", + "with spaces", + "--profile", + "--unsafe", + ]); + expect(result.plan.keys).toEqual(["PATH"]); + } finally { + restoreEnv("AWS_SECRET_ACCESS_KEY", original); + } +}); + +test("preserves structured compiler validation failure", async () => { + const result = { + transport_version: 1, + ok: false, + diagnostics: [ + { + code: "E_UNKNOWN_FIELD", + pointer: "/services/web", + message: "Unsupported field.", + line: 2, + column: 3, + }, + ], + } as const; + const binary = await fixture( + script( + `console.log(${JSON.stringify(JSON.stringify(result))}); process.exitCode = 1;` + ) + ); + expect(await compileNativeConfig({ input: INPUT, binary })).toEqual(result); +}); + +test.each([ + ["invalid JSON", "private-invalid-output", 0], + [ + "bad plan version", + JSON.stringify({ ...SUCCESS, plan: { plan_version: 2 } }), + 0, + ], + [ + "bad semantic hash", + JSON.stringify({ ...SUCCESS, semantic_hash: "nope" }), + 0, + ], + ["wrong exit", JSON.stringify(SUCCESS), 1], + [ + "empty diagnostics", + '{"transport_version":1,"ok":false,"diagnostics":[]}', + 1, + ], + [ + "missing location", + '{"transport_version":1,"ok":false,"diagnostics":[{"code":"x","pointer":"/","message":"private-value"}]}', + 1, + ], + ["usage failure", "", 2], +])("rejects %s without echoing compiler output", async (_name, stdout, exit) => { + const binary = await fixture( + script( + `process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write('private-stderr'); process.exitCode = ${exit};` + ) + ); + const error = await compileNativeConfig({ input: INPUT, binary }).catch( + (value: unknown) => value + ); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain("private"); +}); + +test("bounds stdout and stderr separately", async () => { + for (const stream of ["stdout", "stderr"]) { + const binary = await fixture( + script(`process.${stream}.write('x'.repeat(9 * 1024 * 1024));`) + ); + await expect(compileNativeConfig({ input: INPUT, binary })).rejects.toThrow( + "I/O exceeds its budget" + ); + } +}); + +test("times out and reaps a hanging owned compiler", async () => { + const pidPath = join(directory, "pid"); + const binary = await fixture( + script( + `await Bun.write(${JSON.stringify(pidPath)}, String(process.pid)); await Bun.sleep(10000);` + ) + ); + await expect( + compileNativeConfig({ input: INPUT, binary, timeoutMs: 250 }) + ).rejects.toThrow("timed out"); + const pid = Number(await Bun.file(pidPath).text()); + expect(() => process.kill(pid, 0)).toThrow(); +}); + +test("cancellation reaps the compiler and pre-cancel does not spawn", async () => { + const pidPath = join(directory, "pid"); + const binary = await fixture( + script( + `await Bun.write(${JSON.stringify(pidPath)}, String(process.pid)); await Bun.sleep(10000);` + ) + ); + const signal = AbortSignal.timeout(250); + await expect( + compileNativeConfig({ input: INPUT, binary, signal }) + ).rejects.toThrow("cancelled"); + const pid = Number(await Bun.file(pidPath).text()); + expect(() => process.kill(pid, 0)).toThrow(); + await rm(pidPath); + await expect( + compileNativeConfig({ input: INPUT, binary, signal }) + ).rejects.toThrow("cancelled"); + expect(await Bun.file(pidPath).exists()).toBe(false); +}); + +test("input reads require a bounded regular file", async () => { + const path = join(directory, "project.json"); + await Bun.write(path, INPUT); + expect(await readNativeConfigInput({ path })).toEqual(INPUT); + await Bun.write(path, new Uint8Array(NATIVE_CONFIG_INPUT_LIMIT + 1)); + await expect(readNativeConfigInput({ path })).rejects.toThrow( + "bounded regular file" + ); + await mkdir(join(directory, "folder")); + await expect( + readNativeConfigInput({ path: join(directory, "folder") }) + ).rejects.toThrow("bounded regular file"); + await expect( + readNativeConfigInput({ path: join(directory, "absent") }) + ).rejects.toThrow("Cannot read"); + await expect( + compileNativeConfig({ + input: new Uint8Array(NATIVE_CONFIG_INPUT_LIMIT + 1), + binary: path, + }) + ).rejects.toThrow("input budget"); +}); diff --git a/tests/native-config-dto.test.ts b/tests/native-config-dto.test.ts new file mode 100644 index 000000000..4a8677217 --- /dev/null +++ b/tests/native-config-dto.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import type { 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" }; +// @ts-expect-error schema versions are independently fenced +const future: Project = { schema_version: 2, name: "example" }; +const nullOverlay: Project = { + schema_version: 1, + name: "example", + // @ts-expect-error project overlay null is not the local-file base override + environment: { default_overlay: null }, +}; +const unknown: Project = { + schema_version: 1, + name: "example", + // @ts-expect-error arbitrary backend fields must not become pass-through DTOs + backend_options: {}, +}; +const falseTombstone: Project = { + schema_version: 1, + name: "example", + // @ts-expect-error tagged environment tombstone accepts true only + services: { web: { image: "x:1", environment: { KEY: { unset: false } } } }, +}; +void [future, nullOverlay, unknown, falseTombstone]; + +test("authored DTO permits omitted default fields", () => { + expect(minimal).toEqual({ schema_version: 1, name: "example" }); +}); diff --git a/tests/prerelease-plan.test.ts b/tests/prerelease-plan.test.ts index f495b4a13..c0f21a1b0 100644 --- a/tests/prerelease-plan.test.ts +++ b/tests/prerelease-plan.test.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from "node:os"; import { join } from "node:path"; import { + CONFIG_COMPILER_PAYLOAD, createPrereleasePlan, PRERELEASE_PAYLOAD, packagePrerelease, @@ -502,6 +503,86 @@ async function fixture(root: string) { return bundle; } +async function compilerFixture(bundle: string) { + await writeFile( + join(bundle, "hack-config-compiler"), + "synthetic compiler\n", + { mode: 0o755 } + ); + await chmod(join(bundle, "hack-config-compiler"), 0o755); + await writeFile(join(bundle, "hack.project.schema.json"), "{}\n", { + mode: 0o600, + }); + await chmod(join(bundle, "hack.project.schema.json"), 0o600); + await Bun.write( + join(bundle, "SHA256SUMS"), + await renderChecksums({ + root: bundle, + names: [...PRERELEASE_PAYLOAD, ...CONFIG_COMPILER_PAYLOAD], + }) + ); +} + +test("packages the optional compiler/schema pair beside the CLI", async () => { + const root = await mkdtemp(join(tmpdir(), "hack-compiler-package-")); + try { + const bundle = await fixture(root); + await compilerFixture(bundle); + 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( + [...PRERELEASE_PAYLOAD, ...CONFIG_COMPILER_PAYLOAD, "SHA256SUMS"].sort() + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +for (const name of CONFIG_COMPILER_PAYLOAD) { + for (const corruption of [ + "missing", + "tampered", + "symlink", + "hardlink", + "mode", + ] as const) { + test(`refuses ${corruption} compiler payload ${name}`, async () => { + const root = await mkdtemp(join(tmpdir(), "hack-compiler-refusal-")); + try { + const bundle = await fixture(root); + await compilerFixture(bundle); + const path = join(bundle, name); + if (corruption === "missing") { + await rm(path); + } else if (corruption === "tampered") { + await Bun.write(path, "changed"); + } else if (corruption === "mode") { + await chmod(path, 0o777); + } else if (corruption === "symlink") { + await rm(path); + await symlink("hack-cli", path); + } else { + await link(path, join(root, "alias")); + } + await expect( + packagePrerelease({ + plan: createPrereleasePlan(input), + bundle, + output: join(root, "refused"), + }) + ).rejects.toThrow(); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + } +} + /** Python exposes AppleDouble entries that macOS tar's own listing hides. */ async function archiveMembers(archive: string) { const listing = Bun.spawn( @@ -661,7 +742,12 @@ test("packages a verified MCP selection with exact nested checksums and inventor }); const nested = await nativeCandidateMcpPayload(bundle); expect(nested).toHaveLength(4); - const names = [...PRERELEASE_PAYLOAD, ...nested]; + await compilerFixture(bundle); + const names = [ + ...PRERELEASE_PAYLOAD, + ...CONFIG_COMPILER_PAYLOAD, + ...nested, + ]; await Bun.write( join(bundle, "SHA256SUMS"), await renderChecksums({ root: bundle, names }) diff --git a/tests/python/test_prerelease_install.py b/tests/python/test_prerelease_install.py index 5cb254665..553c02d36 100644 --- a/tests/python/test_prerelease_install.py +++ b/tests/python/test_prerelease_install.py @@ -44,6 +44,14 @@ def with_mcp(entries, change=None): return [(name, value, tarfile.REGTYPE) for name, value in payload.items()] +def with_compiler(entries): + 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"}) + 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()] + + class ChannelTests(unittest.TestCase): def setUp(self): self.temporary = tempfile.TemporaryDirectory(prefix="hack-prerelease-") @@ -175,7 +183,11 @@ def assert_pending_refuses_installed_readers(self, legacy): source = self.channel.root / "manager.py" spec = importlib.util.spec_from_file_location("retained_prerelease_manager", source) retained = importlib.util.module_from_spec(spec) - spec.loader.exec_module(retained) + # Import the retained reader without adding unowned bytecode to its + # channel. This must hold even when unittest itself runs without -B. + with mock.patch.object(sys, "dont_write_bytecode", True): + spec.loader.exec_module(retained) + self.assertFalse((self.channel.root / "__pycache__").exists()) for module in (legacy, retained, installer): for command in ("status", "run"): with self.subTest(reader=module.__name__, command=command), \ @@ -273,6 +285,116 @@ def test_optional_mcp_upgrade_keeps_modes_and_legacy_rollback(self): self.fail("Aliased MCP bundle accepted") self.assertEqual((self.channel.root / ".selection.json").read_bytes(), before) + def test_compiler_pair_upgrade_preserves_old_layouts_and_installed_modes(self): + self.install() + 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)))): + 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: + 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): + with self.channel.lock(): + self.channel.select(prior) + self.assertEqual(self.selection()["selected"], prior) + signed = {Path(arguments[-1]).name for arguments, _ in self.calls + if arguments[0] == "/usr/bin/codesign"} + self.assertIn("hack-config-compiler", signed) + self.assertNotIn("hack.project.schema.json", signed) + + def test_partial_tampered_and_aliased_compiler_archives_preserve_selection(self): + for name in installer.COMPILER_PAYLOAD: + for case in ("missing", "tampered", tarfile.SYMTYPE, tarfile.LNKTYPE): + with self.subTest(name=name, case=case): + def mutate(entries): + return [(key, b"changed" if case == "tampered" and key == name else value, + 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") + + def test_installed_compiler_pair_tampering_and_modes_are_refused(self): + archive, checksum = self.archive(mutate=with_compiler) + with self.channel.lock(): + self.channel.install("5.0.0-next.1", archive, checksum) + bundle = self.channel.root / "versions/5.0.0-next.1/bundle" + for name in installer.COMPILER_PAYLOAD: + path = bundle / name + before = path.read_bytes() + mode = path.stat().st_mode & 0o777 + for case in ("bytes", "mode", "hardlink", "symlink"): + with self.subTest(name=name, case=case): + alias = self.root / "compiler-alias" + if case == "bytes": + path.write_bytes(b"changed") + elif case == "mode": + path.chmod(0o777) + elif case == "hardlink": + os.link(path, alias) + else: + path.rename(alias) + path.symlink_to(alias) + with self.assertRaises(installer.Refusal): + installer.verify_bundle(bundle, "5.0.0-next.1") + if case == "symlink": + path.unlink() + alias.rename(path) + elif case == "hardlink": + alias.unlink() + else: + path.write_bytes(before) + path.chmod(mode) + + def test_failed_compiler_signature_preserves_selection(self): + self.install() + archive, checksum = self.archive("5.0.0-next.2", with_compiler) + before = self.selection() + original = self.process + def fail_compiler(arguments, **options): + if arguments[0] == "/usr/bin/codesign" and Path(arguments[-1]).name == "hack-config-compiler": + return subprocess.CompletedProcess(arguments, 1) + return original(arguments, **options) + with mock.patch.object(installer.subprocess, "run", side_effect=fail_compiler): + with self.channel.lock(): + with self.assertRaisesRegex(installer.Refusal, "signature failed: hack-config-compiler"): + self.channel.install("5.0.0-next.2", archive, checksum, True) + self.assertEqual(self.selection(), before) + + def test_reviewed_mcp_manager_upgrades_before_accepting_compiler_pair(self): + source = SOURCE.parent.parent / "tests/fixtures/prerelease-manager-mcp-v2.py" + self.assertEqual(installer.digest(source), + "ca432b7fc6562bb091d17d3217f5d1daf9f91621a6919c8964ca51bee2111d0c") + spec = importlib.util.spec_from_file_location("mcp_prerelease_manager", source) + previous = importlib.util.module_from_spec(spec) + spec.loader.exec_module(previous) + self.channel = previous.Channel(self.root / "mcp-channel") + self.channel.initialize() + self.install() + 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) + before = self.retained_snapshot() + archive, checksum = self.archive("5.0.0-next.3", with_compiler) + current = installer.Channel(self.channel.root) + with current.lock(): + with self.assertRaisesRegex(installer.Refusal, "upgrade-manager"): + current.install("5.0.0-next.3", archive, checksum, True) + self.assertEqual(self.retained_snapshot(), before) + self.channel = self.upgrade_manager() + self.assertEqual(self.retained_snapshot(), before) + with self.channel.lock(): + self.channel.install("5.0.0-next.3", archive, checksum, True) + self.channel.select("5.0.0-next.2") + self.assertEqual(self.selection()["selected"], "5.0.0-next.2") + def test_mcp_corruption_and_nested_inventory_preserve_previous_selection(self): def manifest_change(payload, prefix, field, value): path = prefix + "manifest.json"