diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0613fd..c69c727 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,15 +13,71 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + with: + submodules: recursive + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + cache-dependency-path: uhura/web/pnpm-lock.yaml + - name: build the canonical Uhura provider + run: | + pnpm -C uhura/web install --frozen-lockfile + pnpm -C uhura/web build:provider + - uses: dtolnay/rust-toolchain@1.92.0 with: components: rustfmt, clippy - uses: Swatinem/rust-cache@v2 - name: fmt run: cargo fmt --all --check - name: clippy - run: cargo clippy --workspace --all-targets -- -D warnings + run: cargo clippy --locked --workspace --all-targets -- -D warnings - name: test - run: cargo test --workspace + run: cargo test --locked --workspace - name: check the example - run: cargo run -p spock-cli -- check examples/instagram/v0.spock + run: cargo run --locked -p spock-cli -- check examples/instagram/v0.spock + + uhura: + runs-on: ubuntu-latest + defaults: + run: + working-directory: uhura + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: dtolnay/rust-toolchain@1.92.0 + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + key: uhura + workspaces: uhura -> target + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + cache-dependency-path: uhura/web/pnpm-lock.yaml + # Canonical host integration consumes the ignored provider bundle built + # from authoritative TypeScript, so build browser products before Rust. + - name: check the Uhura browser and provider + run: | + pnpm -C web install --frozen-lockfile + pnpm -C web check + - name: fmt + run: cargo fmt --all --check + - name: clippy + run: cargo clippy --locked --workspace --all-targets -- -D warnings + - name: test + run: cargo test --locked --workspace --all-targets + - name: check the canonical Uhura project + run: | + cargo run --locked -p uhura-cli -- fmt --check examples/instagram-uhura + cargo run --locked -p uhura-cli -- check examples/instagram-uhura --deny-warnings + cargo run --locked -p uhura-cli -- trace examples/instagram-uhura --script=demo >/dev/null diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 27912dc..802b385 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -1,22 +1,23 @@ name: npm -# Distribution of the `spock` CLI over npm (RFD 0020). One package bundles a -# prebuilt binary per platform; a Node shim (npm/bin/spock.js) execs the one +# Distribution of the `spock` framework CLI over npm (RFDs 0020 and 0022). One +# package bundles a prebuilt binary per platform and one platform-independent +# Uhura web/Wasm sidecar; a Node shim (npm/bin/spock.js) owns the binary # matching the host. Publishing is tokenless via npm Trusted Publishing (OIDC): # this workflow file name (npm.yml) is what the trusted-publisher config on # npmjs.com is pinned to, and `id-token: write` lets `npm publish` authenticate # without a token. # # Trigger it manually (Actions -> npm -> Run workflow) to dry-run or publish a -# prerelease under a dist-tag; push a `vX.Y.Z` tag to cut a real `latest`. +# prerelease under a dist-tag; stable tags publish `latest` and prerelease tags +# publish `next`. on: workflow_dispatch: inputs: version: - description: "Version to publish (e.g. 0.1.0 or 0.1.0-rc.1)" - required: true - default: "0.1.0-rc.1" + description: "Optional version assertion (defaults to Cargo.toml)" + required: false dist_tag: description: "npm dist-tag (keeps prereleases off `latest`)" required: true @@ -32,6 +33,9 @@ on: permissions: contents: read +env: + NPM_CLI_VERSION: 11.6.2 + concurrency: group: npm-publish cancel-in-progress: false @@ -41,19 +45,104 @@ defaults: shell: bash jobs: + assets: + name: build shared framework assets + runs-on: ubuntu-22.04 + timeout-minutes: 45 + outputs: + manifest_sha256: ${{ steps.sidecar.outputs.manifest_sha256 }} + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: pnpm/action-setup@v4 + with: + version: 10.11.0 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + cache-dependency-path: uhura/web/pnpm-lock.yaml + + - name: self-test sidecar tooling + run: node npm/scripts/sidecar.mjs self-test + + - uses: dtolnay/rust-toolchain@1.92.0 + with: + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + key: npm-uhura-wasm + workspaces: uhura -> target + cache-directories: uhura/target/tools + + - name: check and build Uhura Editor, Play, and provider + run: | + pnpm -C uhura/web install --frozen-lockfile + pnpm -C uhura/web check + + - name: install lockfile-exact wasm-bindgen CLI + run: | + version="$(sed -n '/name = "wasm-bindgen"/{n;s/version = "\([^"]*\)"/\1/p;q;}' uhura/Cargo.lock)" + test -n "$version" + installed="" + if [ -x uhura/target/tools/bin/wasm-bindgen ]; then + installed="$(uhura/target/tools/bin/wasm-bindgen --version | awk '{print $2}')" + fi + if [ "$installed" != "$version" ]; then + cargo install wasm-bindgen-cli \ + --version "$version" \ + --locked \ + --force \ + --root uhura/target/tools + fi + test "$(uhura/target/tools/bin/wasm-bindgen --version | awk '{print $2}')" = "$version" + + - name: build Uhura WebAssembly bundle + run: WASM_BINDGEN="$GITHUB_WORKSPACE/uhura/target/tools/bin/wasm-bindgen" bash uhura/scripts/build-wasm.sh + + - name: assemble and guard shared sidecar + id: sidecar + run: | + node npm/scripts/sidecar.mjs assemble \ + --web-dir uhura/web/dist \ + --wasm-dir uhura/crates/uhura-wasm/pkg/web \ + --out-dir npm/share/spock/uhura \ + --spock-commit "$(git rev-parse HEAD)" \ + --uhura-commit "$(git -C uhura rev-parse HEAD)" + node npm/scripts/sidecar.mjs verify --root npm/share/spock/uhura + manifest_sha256="$(sha256sum npm/share/spock/uhura/manifest.json | awk '{print $1}')" + if [[ ! "$manifest_sha256" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::could not derive the sidecar manifest SHA-256" + exit 1 + fi + printf 'manifest_sha256=%s\n' "$manifest_sha256" >> "$GITHUB_OUTPUT" + echo "sidecar manifest SHA-256: $manifest_sha256" + + - uses: actions/upload-artifact@v4 + with: + name: framework-assets + path: npm/share/spock/uhura/ + if-no-files-found: error + retention-days: 1 + build: name: build ${{ matrix.key }} + needs: assets runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - { key: darwin-arm64, os: macos-14, target: aarch64-apple-darwin, smoke: true } - - { key: darwin-x64, os: macos-14, target: x86_64-apple-darwin, smoke: false } + - { key: darwin-x64, os: macos-14, target: x86_64-apple-darwin, smoke: false, rosetta: true } - { key: linux-x64, os: ubuntu-22.04, target: x86_64-unknown-linux-gnu, smoke: true } - { key: win32-x64, os: windows-2025, target: x86_64-pc-windows-msvc, smoke: true } steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: pnpm/action-setup@v4 with: @@ -77,7 +166,7 @@ jobs: test "$bytes" -gt 200 || { echo "::error::$f is only $bytes bytes"; exit 1; } echo "studio dist/index.html: $bytes bytes" - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@1.92.0 with: targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@v2 @@ -85,7 +174,14 @@ jobs: key: ${{ matrix.target }} - name: cargo build --release - run: cargo build --release -p spock-cli --target ${{ matrix.target }} + env: + SPOCK_PACKAGED_UHURA_MANIFEST_SHA256: ${{ needs.assets.outputs.manifest_sha256 }} + run: | + if [[ ! "$SPOCK_PACKAGED_UHURA_MANIFEST_SHA256" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::missing or malformed executable-bound sidecar manifest identity" + exit 1 + fi + cargo build --locked --release -p spock-cli --target ${{ matrix.target }} - name: smoke test (native arch only) if: matrix.smoke @@ -95,6 +191,24 @@ jobs: "$bin" --version "$bin" check examples/instagram/v0.spock + - name: smoke test (macOS x64 through Rosetta) + if: matrix.rosetta + run: | + bin="target/${{ matrix.target }}/release/spock" + if ! file "$bin" | grep -q 'Mach-O 64-bit executable x86_64'; then + echo "::error::$bin is not an x86_64 Mach-O executable" + file "$bin" + exit 1 + fi + if ! arch -x86_64 "$bin" --version; then + echo "::error::the advertised macOS x64 binary did not run through Rosetta" + exit 1 + fi + if ! arch -x86_64 "$bin" check examples/instagram/v0.spock; then + echo "::error::the advertised macOS x64 binary could not load and check a Spock program through Rosetta" + exit 1 + fi + - name: stage binary run: | exe=spock; [ "${{ runner.os }}" = "Windows" ] && exe=spock.exe @@ -110,43 +224,93 @@ jobs: publish: name: publish - needs: build + needs: [assets, build] runs-on: ubuntu-latest permissions: contents: read id-token: write # required for OIDC trusted publishing outputs: version: ${{ steps.resolve.outputs.version }} + cargo_version: ${{ steps.resolve.outputs.cargo_version }} dist_tag: ${{ steps.resolve.outputs.dist_tag }} dry_run: ${{ steps.resolve.outputs.dry_run }} steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-node@v4 with: node-version-file: .nvmrc registry-url: "https://registry.npmjs.org" - - name: use latest npm (Trusted Publishing needs >= 11.5.1) - run: npm install -g npm@latest + - name: install pinned Trusted-Publishing-capable npm + run: npm install -g "npm@$NPM_CLI_VERSION" - name: resolve version, tag, dry_run id: resolve + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_VERSION: ${{ inputs.version }} + INPUT_DIST_TAG: ${{ inputs.dist_tag }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} run: | - if [ "${{ github.event_name }}" = "push" ]; then - v="${GITHUB_REF_NAME#v}"; tag="latest"; dry="false" + cargo_version="$(awk ' + /^\[workspace.package\]$/ { section = 1; next } + /^\[/ { section = 0 } + section && /^version = / { + gsub(/^version = "|"$/, ""); print; exit + } + ' Cargo.toml)" + test -n "$cargo_version" + if [ "$EVENT_NAME" = "push" ]; then + v="${GITHUB_REF_NAME#v}"; dry="false" else - v="${{ inputs.version }}"; tag="${{ inputs.dist_tag }}"; dry="${{ inputs.dry_run }}" + v="${INPUT_VERSION:-$cargo_version}"; tag="$INPUT_DIST_TAG"; dry="$INPUT_DRY_RUN" fi - printf 'version=%s\n' "$v" >> "$GITHUB_OUTPUT" - printf 'dist_tag=%s\n' "$tag" >> "$GITHUB_OUTPUT" - printf 'dry_run=%s\n' "$dry" >> "$GITHUB_OUTPUT" - echo "resolved: version=$v dist_tag=$tag dry_run=$dry" + # Every successful matrix build has already made Cargo parse this + # workspace version. Keep Cargo authoritative instead of maintaining + # a second, inevitably incomplete SemVer grammar in bash. + if [ "$v" != "$cargo_version" ]; then + echo "::error::release version $v does not match Cargo workspace version $cargo_version" + exit 1 + fi + package_version="${cargo_version%%+*}" + if [[ "$package_version" == *-* ]]; then + prerelease="true" + else + prerelease="false" + fi + if [ "$EVENT_NAME" = "push" ]; then + if [ "$prerelease" = "true" ]; then tag="next"; else tag="latest"; fi + fi + if [[ ! "$tag" =~ ^[A-Za-z][A-Za-z0-9._-]*$ ]]; then + echo "::error::invalid npm dist-tag: $tag" + exit 1 + fi + if [ "$prerelease" = "true" ] && [ "$tag" = "latest" ]; then + echo "::error::prerelease version $v may not publish to the latest dist-tag" + exit 1 + fi + if [ "$dry" != "true" ] && [ "$dry" != "false" ]; then + echo "::error::dry_run must be true or false" + exit 1 + fi + printf 'version=%s\n' "$package_version" >> "$GITHUB_OUTPUT" + printf 'cargo_version=%s\n' "$cargo_version" >> "$GITHUB_OUTPUT" + printf 'dist_tag=%s\n' "$tag" >> "$GITHUB_OUTPUT" + printf 'dry_run=%s\n' "$dry" >> "$GITHUB_OUTPUT" + echo "resolved: cargo_version=$cargo_version package_version=$package_version dist_tag=$tag dry_run=$dry" - uses: actions/download-artifact@v4 with: path: artifacts pattern: bin-* + - uses: actions/download-artifact@v4 + with: + name: framework-assets + path: npm/share/spock/uhura + - name: assemble platform binaries into the package run: | set -e @@ -155,9 +319,10 @@ jobs: exe=spock; [ "$key" = "win32-x64" ] && exe=spock.exe mkdir -p "npm/binaries/$key" cp "$d/$exe" "npm/binaries/$key/$exe" - [ "$key" = "win32-x64" ] || chmod +x "npm/binaries/$key/$exe" + [ "$key" = "win32-x64" ] || chmod 0755 "npm/binaries/$key/$exe" done - echo "=== assembled ==="; ls -laR npm/binaries + echo "=== assembled ===" + ls -laR npm/binaries - name: guard — all four platforms present run: | @@ -167,12 +332,99 @@ jobs: test -s "npm/binaries/$key/$exe" || { echo "::error::missing binary for $key"; exit 1; } done + - name: guard — shared framework sidecar is complete + run: node npm/scripts/sidecar.mjs verify --root npm/share/spock/uhura + - name: stamp version - run: cd npm && npm version "${{ steps.resolve.outputs.version }}" --no-git-tag-version --allow-same-version + env: + PACKAGE_VERSION: ${{ steps.resolve.outputs.version }} + run: cd npm && npm version "$PACKAGE_VERSION" --no-git-tag-version --allow-same-version + + - name: guard — packed package is complete and within 25 MiB + env: + PACKAGE_VERSION: ${{ steps.resolve.outputs.version }} + run: | + mkdir -p "$RUNNER_TEMP/npm-pack" + (cd npm && npm pack --json --pack-destination "$RUNNER_TEMP/npm-pack") \ + > "$RUNNER_TEMP/npm-pack.json" + shopt -s nullglob + packages=("$RUNNER_TEMP/npm-pack/"*.tgz) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}" + exit 1 + fi + node - "$RUNNER_TEMP/npm-pack.json" <<'NODE' + const fs = require("node:fs"); + const [entry] = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + if (!entry || !Number.isSafeInteger(entry.size)) { + throw new Error("npm pack did not report a package size"); + } + if (entry.version !== process.env.PACKAGE_VERSION) { + throw new Error( + `packed npm version ${entry.version} does not match ${process.env.PACKAGE_VERSION}`, + ); + } + const manifest = JSON.parse( + fs.readFileSync("npm/share/spock/uhura/manifest.json", "utf8"), + ); + const expected = new Set([ + "LICENSE", + "README.md", + "THIRD_PARTY_NOTICES.md", + "bin/spock.js", + "binaries/darwin-arm64/spock", + "binaries/darwin-x64/spock", + "binaries/linux-x64/spock", + "binaries/win32-x64/spock.exe", + "package.json", + "share/spock/uhura/manifest.json", + ...manifest.files.map((file) => `share/spock/uhura/${file.path}`), + ]); + const packed = new Map(entry.files.map((file) => [file.path, file])); + for (const required of expected) { + if (!packed.has(required)) throw new Error(`packed package is missing ${required}`); + } + for (const actual of packed.keys()) { + if (!expected.has(actual)) throw new Error(`packed package has unexpected ${actual}`); + } + for (const path of [ + "bin/spock.js", + "binaries/darwin-arm64/spock", + "binaries/darwin-x64/spock", + "binaries/linux-x64/spock", + ]) { + const mode = packed.get(path)?.mode; + if (!Number.isSafeInteger(mode) || (mode & 0o777) !== 0o755) { + throw new Error(`packed executable must have mode 0755: ${path} (mode ${mode})`); + } + } + const limit = 25 * 1024 * 1024; + if (entry.size > limit) { + throw new Error(`packed package is ${entry.size} bytes; limit is ${limit} bytes`); + } + console.log(`packed package: ${entry.size} bytes (${entry.unpackedSize} unpacked)`); + NODE + + - name: upload guarded npm tarball + uses: actions/upload-artifact@v4 + with: + name: npm-package + path: ${{ runner.temp }}/npm-pack/*.tgz + if-no-files-found: error + retention-days: 1 - name: npm publish (dry run) if: steps.resolve.outputs.dry_run == 'true' - run: cd npm && npm publish --dry-run --tag "${{ steps.resolve.outputs.dist_tag }}" + env: + DIST_TAG: ${{ steps.resolve.outputs.dist_tag }} + run: | + shopt -s nullglob + packages=("$RUNNER_TEMP/npm-pack/"*.tgz) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}" + exit 1 + fi + npm publish "${packages[0]}" --dry-run --tag "$DIST_TAG" - name: npm publish if: steps.resolve.outputs.dry_run != 'true' @@ -180,12 +432,20 @@ jobs: # races the tarball PUT and returns a false "cannot publish over # previously published version" (npm/cli). Tokenless OIDC auth is # independent of provenance and still applies. - run: cd npm && npm publish --tag "${{ steps.resolve.outputs.dist_tag }}" --no-provenance + env: + DIST_TAG: ${{ steps.resolve.outputs.dist_tag }} + run: | + shopt -s nullglob + packages=("$RUNNER_TEMP/npm-pack/"*.tgz) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}" + exit 1 + fi + npm publish "${packages[0]}" --tag "$DIST_TAG" --no-provenance verify: name: verify ${{ matrix.key }} needs: publish - if: needs.publish.outputs.dry_run != 'true' runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -196,18 +456,60 @@ jobs: - { key: win32-x64, os: windows-2025 } steps: - uses: actions/checkout@v4 + with: + submodules: recursive - uses: actions/setup-node@v4 with: node-version-file: .nvmrc - - name: install spock from npm + - name: download guarded npm tarball + if: needs.publish.outputs.dry_run == 'true' + uses: actions/download-artifact@v4 + with: + name: npm-package + path: packed-npm + + - name: install spock from guarded tarball + if: needs.publish.outputs.dry_run == 'true' + run: | + shopt -s nullglob + packages=(packed-npm/*.tgz) + if [ "${#packages[@]}" -ne 1 ]; then + echo "::error::expected exactly one guarded npm tarball; found ${#packages[@]}" + exit 1 + fi + package_path="$(pwd)/${packages[0]}" + npm install -g "$package_path" + echo "installed $package_path" + + - name: install spock from npm registry + if: needs.publish.outputs.dry_run != 'true' + env: + PACKAGE_VERSION: ${{ needs.publish.outputs.version }} run: | - v="${{ needs.publish.outputs.version }}" + installed="" for i in $(seq 1 12); do - if npm install -g "spock@$v"; then echo "installed spock@$v"; break; fi - echo "attempt $i: spock@$v not visible yet; waiting"; sleep 6 + if npm install -g "spock@$PACKAGE_VERSION"; then installed=1; echo "installed spock@$PACKAGE_VERSION"; break; fi + echo "attempt $i: spock@$PACKAGE_VERSION not visible yet; waiting"; sleep 6 done - spock --version + test -n "$installed" || { echo "::error::spock@$PACKAGE_VERSION was not installable"; exit 1; } + + - name: verify installed npm and Cargo versions + env: + PACKAGE_VERSION: ${{ needs.publish.outputs.version }} + CARGO_VERSION: ${{ needs.publish.outputs.cargo_version }} + run: | + package_root="$(npm root -g)" + installed_version="$(node -e ' + const path = require("node:path"); + const manifest = require(path.join(process.argv[1], "spock", "package.json")); + process.stdout.write(manifest.version); + ' "$package_root")" + test "$installed_version" = "$PACKAGE_VERSION" + test "$(spock --version)" = "spock $CARGO_VERSION" + + - name: verify installed framework sidecar + run: node npm/scripts/sidecar.mjs verify --root "$(npm root -g)/spock/share/spock/uhura" - name: spock check run: spock check examples/instagram/v0.spock @@ -225,3 +527,97 @@ jobs: if [ -z "$ok" ]; then echo "::error::server did not start"; kill "$pid" 2>/dev/null || true; exit 1; fi curl -fsS http://127.0.0.1:4123/~studio | grep -qi "/dev/null || true + + - name: spock framework routes + shared assets served + if: runner.os != 'Windows' + run: | + smoke_root="$RUNNER_TEMP/spock-framework-smoke" + mkdir -p "$smoke_root" + (cd "$smoke_root" && spock new npm-smoke) + spock start "$smoke_root/npm-smoke" --port 4124 >"$smoke_root/server.log" 2>&1 & + pid=$! + cleanup() { + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + } + trap cleanup EXIT + ok="" + for i in $(seq 1 40); do + if curl -fsS http://127.0.0.1:4124/~health >/dev/null 2>&1; then ok=1; break; fi + if ! kill -0 "$pid" 2>/dev/null; then break; fi + sleep 0.25 + done + if [ -z "$ok" ]; then + echo "::error::framework server did not become ready" + cat "$smoke_root/server.log" + exit 1 + fi + curl -fsS http://127.0.0.1:4124/ | grep -qi "/dev/null + curl -fsS http://127.0.0.1:4124/api/play/wasm/uhura_wasm_bg.wasm >/dev/null + curl -fsS http://127.0.0.1:4124/~project/status | grep -q '"protocol":"spock-project-status/1"' + curl -fsS http://127.0.0.1:4124/~project/environment | grep -q '"protocol":"spock-host-environment/1"' + + - name: spock framework routes + shared assets served (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + $smokeRoot = Join-Path $env:RUNNER_TEMP "spock-framework-smoke" + $project = Join-Path $smokeRoot "npm-smoke" + $stdout = Join-Path $smokeRoot "server.stdout.log" + $stderr = Join-Path $smokeRoot "server.stderr.log" + New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null + + $packageRoot = (& npm root -g).Trim() + $shim = Join-Path $packageRoot "spock/bin/spock.js" + Push-Location $smokeRoot + try { + & node $shim new npm-smoke + if ($LASTEXITCODE -ne 0) { throw "spock new failed with exit code $LASTEXITCODE" } + } finally { + Pop-Location + } + + $node = (Get-Command node -ErrorAction Stop).Source + $arguments = @("`"$shim`"", "start", "`"$project`"", "--port", "4124") + $process = Start-Process -FilePath $node -ArgumentList $arguments -PassThru ` + -RedirectStandardOutput $stdout -RedirectStandardError $stderr + try { + $ready = $false + for ($attempt = 0; $attempt -lt 40; $attempt += 1) { + if ($process.HasExited) { break } + try { + $health = Invoke-RestMethod -Uri "http://127.0.0.1:4124/~health" -TimeoutSec 1 + if ($health.ready -eq $true) { $ready = $true; break } + } catch { + Start-Sleep -Milliseconds 250 + } + } + if (-not $ready) { + if (Test-Path $stdout) { Get-Content $stdout } + if (Test-Path $stderr) { Get-Content $stderr } + throw "framework server did not become ready" + } + + $editor = (Invoke-WebRequest -Uri "http://127.0.0.1:4124/").Content + $play = (Invoke-WebRequest -Uri "http://127.0.0.1:4124/play").Content + if ($editor -notmatch " It's only logical. -Spock is an early programming language for prototyping application backends as -a small, inspectable source of truth. +Spock is an early meta-framework for prototyping an application as one small, +inspectable source of truth: a Spock authority backend plus an optional Uhura +client, checked and served by one `spock` command on one origin. The Spock +language remains independently usable for backend-only experiments. Most application backends spread the same intent across too many layers: database schema, API serializers, mutation handlers, validation, authorization, @@ -19,11 +21,24 @@ the spec. ## Install -Spock ships as a single npm package with a prebuilt binary for macOS, Linux, -and Windows — no build step, no toolchain: +Spock ships as a single npm package with prebuilt binaries for macOS arm64/x64, +GNU-libc Linux x64, and Windows x64 — no build step, no toolchain. Alpine and +other musl-based Linux distributions are not supported yet. Framework commands +begin with `0.5.0`; registry releases through `0.4.0` expose only the standalone +language commands. + +With the `0.5.0` framework npm release: + +```sh +npx spock new demo +cd demo +npx spock dev +``` + +Standalone programs keep the same entry points: ```sh -# run without installing +npx spock check app.spock npx spock run app.spock # or install globally @@ -31,11 +46,64 @@ npm i -g spock ``` ```sh -spock check app.spock # parse + check a program -spock run app.spock # materialize + serve (GraphQL, REST, /~studio) +spock new demo # create backend + Uhura client + spock.toml +spock check # check the whole nearest project +spock dev # watch client; observe backend as restart-required +spock start # serve one fixed combined generation + +# standalone language escape hatches stay available +spock check app.spock # parse + fully load-check one program +spock run app.spock # materialize + serve GraphQL, REST, and Studio spock gen types app.spock # emit TypeScript types ``` +## Framework projects + +`spock new NAME` creates this canonical topology; `--backend-only` omits the +client. `spock init [PATH]` adopts unambiguous existing sources without moving +or overwriting them. + +```text +demo/ +├── spock.toml +├── backend/ +│ └── app.spock +└── client/ + ├── uhura.toml + └── ... +``` + +The manifest is required, as is its configured `.spock` entry. A project whose +authority is not designed yet should keep that file empty; the client, health, +status, contract metadata, Editor, and Play can still run. + +`spock dev` deliberately has asymmetric reload semantics today. Valid Uhura +client saves publish live. Invalid saves retain the last good Play generation +when one exists; an initially invalid client is reported as `cold_invalid` +while Editor diagnostics remain available. Backend inputs—including the +`.spock` source and referenced seed assets—and topology-affecting `spock.toml` +saves are detected and reported as `restart_required`, but they never reopen, +reseed, migrate, or replace the active database. Restarting reconstructs +backend state from seed. This keeps the current behavior honest while the +development-state model in +[RFD 0023](docs/rfd/0023-development-state-reload.md) remains open. + +Framework composition changes the operational envelope, not the doctrine. +Spock still owns durable product truth, policy, and guarded mutations; Uhura +owns presentation, experience transitions, and non-authoritative UI-session +state. `spock.toml` only composes their roots and lifecycle—it does not merge +their languages or move authority into the client. No fact may become +authoritative in both systems just because one command serves them together. + +For a project with a configured client, both framework commands serve Uhura +Editor at `/` and Play at `/play`. Both modes serve Spock Studio at `/~studio` +and the authority protocols on the same origin; the combined host grants no +cross-origin CORS access by default. A backend-only project's `/` redirects to +Studio and its client routes return structured 404 responses. +`/~project/status` makes the active and merely observed generations explicit; +`/~health` remains ready-but-degraded when a client candidate is rejected or a +backend restart is required. + ## The picture Today, one product rule — say, *"a member may publish a draft post"* — is @@ -623,8 +691,11 @@ types` turns the contract into TypeScript — row and write shapes plus the derived error codes as literal unions — and `spock gen graphql-schema` prints the SDL for offline schema tooling. -The npm package metadata lives under `npm/` only to reserve the package name. -It is not the primary implementation target. +The package source under `npm/` is the primary no-checkout distribution path. +Starting with `0.5.0`, it carries all four native binaries plus one +platform-independent Uhura Editor/Play and WebAssembly sidecar; the small Node +shim only selects and owns the matching native process. Releases through +`0.4.0` predate that framework package and remain standalone-only. ## Uhura, the client language @@ -637,43 +708,57 @@ UI-session state and experience behavior, with Spock as its canonical provider. No fact may be authoritative in both languages. Uhura is a subsystem of the Spock project: its canonical source lives in its -own repository and is included here as a git submodule at `uhura/`. Spock's -core workspace, default build, and CI remain independent of the submodule, so -`cargo` builds work without it; clone with `--recurse-submodules` for the -explicit composition runner below. Once Uhura is minimally stable, its tooling -ships through the unified `spock` toolchain, and the runtime integration lands -as contract projection plus a provider adapter. +own repository and is included here as a git submodule at `uhura/`. The root +Cargo workspace deliberately excludes Uhura's own workspace, while +`spock-host` consumes `uhura-host` through a path dependency. Consequently a +source build of the framework requires an initialized recursive submodule; +framework npm releases contain the resulting runtime and assets. -From an umbrella checkout, the general composition runner accepts any Spock -program and Uhura project. For the Instagram example: +For a source checkout, build the Uhura web and WebAssembly assets, then provide +both roots together when running the framework host. Build Studio first too: +its `dist/` directory exists in a clean checkout but is intentionally empty +until the SPA build runs. ```sh -./scripts/spock-uhura.sh \ - examples/instagram-poc/app.spock \ - uhura/examples/instagram-uhura +git submodule update --init --recursive +corepack pnpm@10.11.0 -C crates/spock-runtime/studio install --frozen-lockfile +corepack pnpm@10.11.0 -C crates/spock-runtime/studio build +corepack pnpm@10.11.0 -C uhura/web install --frozen-lockfile +corepack pnpm@10.11.0 -C uhura/web check +bash uhura/scripts/build-wasm.sh + +cargo run --locked -p spock-cli -- new demo + +SPOCK_UHURA_WEB_DIST="$PWD/uhura/web/dist" \ +SPOCK_UHURA_WASM_DIST="$PWD/uhura/crates/uhura-wasm/pkg/web" \ +cargo run --locked -p spock-cli -- dev demo ``` -It builds the two Rust launchers, starts the requested Spock authority on port -4000, waits for it to become ready, and opens the requested Uhura project -through its read-only Editor on . The Editor's Play -button enters the live prototype at `/play` without starting another process. -`--spock-port` and `--uhura-port` override those defaults; the project's -provider configuration must address the same Spock port. `Ctrl-C` stops both -runtimes. Contributor frontend tooling in Spock and Uhura uses the same Node 24 -LTS pin from their respective `.nvmrc` files and pnpm 10.11.0; the runner uses -them to build Uhura's web application before launch, but neither running server -depends on a Node process. +`build-wasm.sh` reports the lockfile-exact `wasm-bindgen-cli` install command +when that tool is missing. The two asset overrides are a pair: setting only one +is rejected, so a source host cannot accidentally combine incompatible web and +Wasm generations. A distributed `spock` finds the sidecar beside its installed +executable, verifies the executable-bound manifest identity and its file +inventory, and needs neither override nor a Node process at runtime. The paired +overrides are an explicit unanchored source/test trust boundary. + +The historical `scripts/spock-uhura.sh` two-process runner remains a transition +and comparison oracle for examples whose backend and client still live in +separate roots. It is not the canonical framework topology; `spock start` and +`spock dev` own one project, listener, origin, and lifecycle. ## Repository Layout -- `examples/` contains product requirements and current-valid Spock examples. -- `docs/rfd/` contains discussion drafts and proposal-only language ideas. -- `npm/` contains package metadata for npm name reservation. -- `scripts/` contains umbrella composition tooling that keeps Spock and Uhura - independently buildable. -- `uhura/` is the Uhura client language (git submodule of - [gridaco/uhura](https://github.com/gridaco/uhura); not yet wired into the - build). +- `crates/spock-lang/` parses and checks the Spock language and contract IR. +- `crates/spock-runtime/` materializes and serves one authority backend. +- `crates/spock-project/` owns `spock.toml`, discovery, and project validation. +- `crates/spock-host/` coordinates combined generations, routes, and assets. +- `crates/spock-cli/` exposes framework, project, and language commands as the + single `spock` binary. +- `npm/` is the real four-platform distribution package and shared sidecar. +- `uhura/` is the wired Uhura client-language submodule with its own workspace. +- `scripts/` retains transition and comparison tooling, not a second canonical + runtime topology. ## References and prior work @@ -698,8 +783,9 @@ complete mediation, information hiding, and the rule of least power. ## Status -Spock is currently a design-stage proposal. There is no compiler, runtime, or -stable specification yet. +Spock is an early prototype with a working compiler, embedded runtime, +standalone language server, and combined framework host. Its language and host +protocols remain pre-1.0 and may change; it is for prototyping, not production. The older, more ambitious draft has been moved to `docs/rfd/0000-vision.spock`. It is a sketch of possible direction, not the v0 diff --git a/crates/spock-cli/Cargo.toml b/crates/spock-cli/Cargo.toml index 18c1ff9..33b5432 100644 --- a/crates/spock-cli/Cargo.toml +++ b/crates/spock-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "spock-cli" -description = "The spock command: check, build, run" +description = "The Spock framework, project, and language command" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -14,11 +14,25 @@ path = "src/main.rs" [dependencies] spock-lang = { path = "../spock-lang" } spock-runtime = { path = "../spock-runtime" } +spock-project = { path = "../spock-project" } +spock-host = { path = "../spock-host" } serde_json.workspace = true clap.workspace = true anyhow.workspace = true tokio.workspace = true +thiserror.workspace = true + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", features = ["fs"] } + +[target.'cfg(windows)'.dependencies] +cap-fs-ext = "4.0.2" +cap-std = "4.0.2" +fs_at = "0.2.1" +same-file.workspace = true +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem", "Wdk_Storage_FileSystem"] } [dev-dependencies] assert_cmd.workspace = true predicates.workspace = true +tempfile.workspace = true diff --git a/crates/spock-cli/src/lib.rs b/crates/spock-cli/src/lib.rs new file mode 100644 index 0000000..d5248f6 --- /dev/null +++ b/crates/spock-cli/src/lib.rs @@ -0,0 +1,491 @@ +#![forbid(unsafe_code)] + +//! Reusable implementation of Spock's project and standalone-language commands. +//! +//! This crate deliberately keeps argument parsing and terminal presentation in +//! the `spock` binary. It owns the command-layer workflows for discovering, +//! checking, creating, and adopting framework projects, while retaining the +//! language-level escape hatches that load one `.spock` file, derive +//! build/codegen artifacts, or prepare the historical standalone server. +//! Callers can reuse those workflows without going through Clap or spawning a +//! child process; runtime generation and HTTP hosting remain in `spock-host`. + +mod project_commands; +mod write_plan; + +pub use project_commands::{ + check_target, create_project, init_project, resolve_project_for_serve, CheckTargetError, + CheckTargetSummary, NewProjectNameError, ProjectWriteError, ProjectWriteOperation, + ProjectWriteSummary, ResolveProjectForServeError, +}; + +pub use write_plan::{ + ApplyError, ApplyStage, ApplySummary, CreatedPathKind, RollbackReport, RollbackResidual, +}; + +use std::fmt; +use std::future::Future; +use std::io; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::sync::Arc; + +use spock_lang::diag::Diagnostic; +use spock_lang::ir::Contract; + +/// A source file and the checked contract derived from its exact contents. +#[derive(Debug)] +pub struct FileProgram { + /// The caller's spelling, retained for byte-compatible diagnostics. + path: PathBuf, + /// The path used for I/O and relative `file(...)` seed assets. + read_path: PathBuf, + source: String, + contract: Contract, +} + +impl FileProgram { + /// Read and compile one `.spock` source file. + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + Self::load_from(path, path) + } + + /// Load a caller-spelled path as though the process were running in `cwd`. + /// + /// Project target resolution canonicalizes directories, but standalone + /// file mode must not canonicalize the final `.spock` component: doing so + /// changes both rendered diagnostics and the directory used by seed + /// `file(...)` references when the source itself is a symlink. + pub(crate) fn load_from_cwd(path: &Path, cwd: &Path) -> Result { + let read_path = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + Self::load_from(path, &read_path) + } + + fn load_from(display_path: &Path, read_path: &Path) -> Result { + let path = display_path.to_path_buf(); + let read_path = read_path.to_path_buf(); + let source = + std::fs::read_to_string(&read_path).map_err(|error| ProgramLoadError::Read { + path: path.clone(), + error, + })?; + let contract = + spock_lang::compile(&source).map_err(|diagnostics| ProgramLoadError::Diagnostics { + path: path.clone(), + source: source.clone(), + diagnostics, + })?; + Ok(Self { + path, + read_path, + source, + contract, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn source_dir(&self) -> PathBuf { + source_dir(&self.read_path) + } + + pub fn contract(&self) -> &Contract { + &self.contract + } + + pub fn into_contract(self) -> Contract { + self.contract + } +} + +/// Failure to read or compile a file program. +#[derive(Debug)] +pub enum ProgramLoadError { + Read { + path: PathBuf, + error: std::io::Error, + }, + Diagnostics { + path: PathBuf, + source: String, + diagnostics: Vec, + }, +} + +impl ProgramLoadError { + pub fn diagnostics(&self) -> Option<&[Diagnostic]> { + match self { + Self::Diagnostics { diagnostics, .. } => Some(diagnostics), + Self::Read { .. } => None, + } + } +} + +impl fmt::Display for ProgramLoadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read { path, error } => { + write!(f, "error: could not read {}: {error}", path.display()) + } + Self::Diagnostics { + path, + source, + diagnostics, + } => { + let display_path = path.display().to_string(); + for diagnostic in diagnostics { + writeln!(f, "{}", diagnostic.render(source, &display_path))?; + } + write!( + f, + "error: {} diagnostic(s), contract not produced", + diagnostics.len() + ) + } + } + } +} + +impl std::error::Error for ProgramLoadError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Read { error, .. } => Some(error), + Self::Diagnostics { .. } => None, + } + } +} + +/// The directory a `.spock` file lives in: the root for seed `file("...")` +/// assets. A path with no parent retains the historical empty, cwd-relative +/// base directory. +pub fn source_dir(file: impl AsRef) -> PathBuf { + file.as_ref() + .parent() + .map(Path::to_path_buf) + .unwrap_or_default() +} + +/// Successful result of the full `check` load proof. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CheckSummary { + pub tables: usize, + pub records: usize, + pub functions: usize, + pub unchecked_escapes: usize, + pub seed_rows: usize, +} + +impl fmt::Display for CheckSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let functions = if self.functions == 0 { + "0 fn(s)".to_string() + } else { + format!( + "{} fn(s) ({} unchecked escapes)", + self.functions, self.unchecked_escapes + ) + }; + write!( + f, + "ok: {} table(s), {} record(s), {functions}, {} seed row(s)", + self.tables, self.records, self.seed_rows + ) + } +} + +/// Materialize in memory, validate function bodies and checks, prove defaults, +/// and replay seed data: everything `spock run` would reject before serving. +pub fn full_load_check( + contract: &Contract, + base_dir: impl AsRef, +) -> anyhow::Result { + spock_runtime::engine::open(contract, None, Some(base_dir.as_ref()))?; + Ok(CheckSummary { + tables: contract.tables.len(), + records: contract.records.len(), + functions: contract.fns.len(), + unchecked_escapes: contract.fns.iter().map(|function| function.sql.len()).sum(), + seed_rows: contract.seed.len(), + }) +} + +/// Pretty JSON emitted by `spock build`. +pub fn build_artifact(contract: &Contract) -> String { + serde_json::to_string_pretty(contract).expect("contract serializes") +} + +/// A derived `spock gen` artifact. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GenerationTarget { + Types, + GraphqlSchema, +} + +pub fn generate_artifact(contract: &Contract, target: GenerationTarget) -> anyhow::Result { + match target { + GenerationTarget::Types => { + spock_lang::typescript::typescript(contract).map_err(anyhow::Error::from) + } + GenerationTarget::GraphqlSchema => graphql_sdl(contract), + } +} + +/// Derive the runtime's GraphQL SDL without letting seed-data failures gate a +/// data-independent artifact. +fn graphql_sdl(contract: &Contract) -> anyhow::Result { + let mut contract = contract.clone(); + contract.seed.clear(); + let conn = spock_runtime::engine::open(&contract, None, None)?; + let app = Arc::new(spock_runtime::App::new(contract, conn)); + Ok(spock_runtime::graphql::schema(app)?.sdl()) +} + +/// Counts and capabilities reported when a standalone run has materialized. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct StandaloneRunSummary { + pub tables: usize, + pub functions: usize, + pub seed_rows: usize, + pub storage: bool, +} + +impl fmt::Display for StandaloneRunSummary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "spock v0 — contract loaded: {} table(s), {} fn(s), {} seed row(s) replayed", + self.tables, self.functions, self.seed_rows + ) + } +} + +/// A fully materialized historical standalone Spock server. +/// +/// Construction is independent of Clap and terminal output. The app accessor +/// also lets an embedding host reuse the exact runtime state without binding a +/// second listener. +pub struct StandaloneRun { + app: Arc, + summary: StandaloneRunSummary, + // Declared last so the database-backed app is dropped before its + // process-lifetime advisory lock is released. + _named_state_lock: Option, +} + +impl StandaloneRun { + pub fn construct( + contract: Contract, + database_path: Option<&Path>, + base_dir: impl AsRef, + ) -> anyhow::Result { + // `engine::open` deliberately deletes an existing database, WAL, and + // SHM before reconstructing from seed. Acquire the shared framework + // lock first so a second standalone/framework process can never race + // that destructive boundary. + let named_state_lock = database_path + .map(spock_host::NamedStateLock::acquire) + .transpose()?; + let resolved_database_path = named_state_lock + .as_ref() + .map(spock_host::NamedStateLock::resolved_database_path); + let conn = spock_runtime::engine::open( + &contract, + resolved_database_path, + Some(base_dir.as_ref()), + )?; + let summary = StandaloneRunSummary { + tables: contract.tables.len(), + functions: contract.fns.len(), + seed_rows: contract.seed.len(), + storage: spock_runtime::storage::storage_active(&contract), + }; + Ok(Self { + app: Arc::new(spock_runtime::App::new(contract, conn)), + summary, + _named_state_lock: named_state_lock, + }) + } + + pub fn app(&self) -> Arc { + Arc::clone(&self.app) + } + + pub fn summary(&self) -> StandaloneRunSummary { + self.summary + } + + /// Bind the historical loopback listener and serve until an interactive + /// process-shutdown signal or a server failure. `on_listening` runs after + /// a successful bind so the binary can retain its exact presentation + /// without coupling it here. + pub fn serve_until_ctrl_c(self, port: u16, on_listening: impl FnOnce()) -> anyhow::Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + let shutdown = { + let _runtime_guard = runtime.enter(); + install_standalone_shutdown_signal()? + }; + runtime.block_on(async move { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?; + on_listening(); + tokio::select! { + result = spock_runtime::http::serve(self.app, listener) => result?, + result = shutdown => result?, + } + Ok::<(), anyhow::Error>(()) + }) + } +} + +type StandaloneShutdownSignal = Pin> + Send>>; + +#[cfg(unix)] +fn install_standalone_shutdown_signal() -> io::Result { + use tokio::signal::unix::{signal, SignalKind}; + + let mut interrupt = signal(SignalKind::interrupt())?; + let mut terminate = signal(SignalKind::terminate())?; + Ok(Box::pin(async move { + tokio::select! { + received = interrupt.recv() => require_standalone_signal(received, "SIGINT"), + received = terminate.recv() => require_standalone_signal(received, "SIGTERM"), + } + })) +} + +#[cfg(windows)] +fn install_standalone_shutdown_signal() -> io::Result { + use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close}; + + let mut interrupt = ctrl_c()?; + let mut break_signal = ctrl_break()?; + let mut close = ctrl_close()?; + Ok(Box::pin(async move { + tokio::select! { + received = interrupt.recv() => require_standalone_signal(received, "Ctrl-C"), + received = break_signal.recv() => require_standalone_signal(received, "Ctrl-Break"), + received = close.recv() => require_standalone_signal(received, "console close"), + } + })) +} + +#[cfg(not(any(unix, windows)))] +fn install_standalone_shutdown_signal() -> io::Result { + Ok(Box::pin(tokio::signal::ctrl_c())) +} + +#[cfg(any(unix, windows))] +fn require_standalone_signal(received: Option<()>, name: &str) -> io::Result<()> { + received.ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + format!("{name} signal stream closed before delivering a signal"), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load_error_owns_stably_rendered_diagnostics() { + let path = std::env::temp_dir().join(format!( + "spock-cli-library-diagnostic-{}.spock", + std::process::id() + )); + std::fs::write(&path, "table a { x: nope }").expect("write source"); + + let error = FileProgram::load(&path).expect_err("source must fail checking"); + let rendered = error.to_string(); + assert!(rendered.contains("error[E003]"), "{rendered}"); + assert!(rendered.contains("error[E005]"), "{rendered}"); + assert!(rendered.ends_with("error: 2 diagnostic(s), contract not produced")); + + let _ = std::fs::remove_file(path); + } + + #[test] + fn full_load_summary_and_build_artifact_are_reusable() { + let contract = spock_lang::compile( + "table user { key id: uuid = auto\n username: text unique }\n\ + seed { user { username: \"maya\" } }", + ) + .expect("contract"); + + let summary = full_load_check(&contract, "").expect("full load proof"); + assert_eq!( + summary.to_string(), + "ok: 1 table(s), 0 record(s), 0 fn(s), 1 seed row(s)" + ); + let artifact: serde_json::Value = + serde_json::from_str(&build_artifact(&contract)).expect("contract JSON"); + assert_eq!(artifact["tables"][0]["name"], "user"); + } + + #[test] + fn generation_and_standalone_construction_are_library_operations() { + let contract = + spock_lang::compile("table user { key id: uuid = auto\n username: text unique }") + .expect("contract"); + + let types = generate_artifact(&contract, GenerationTarget::Types).expect("types"); + assert!(types.contains("export interface user {"), "{types}"); + let schema = generate_artifact(&contract, GenerationTarget::GraphqlSchema).expect("SDL"); + assert!(schema.contains("type Query"), "{schema}"); + assert!(schema.contains("user("), "{schema}"); + + let run = StandaloneRun::construct(contract, None, "").expect("standalone run"); + assert_eq!( + run.summary(), + StandaloneRunSummary { + tables: 1, + functions: 0, + seed_rows: 0, + storage: false, + } + ); + assert_eq!(run.app().contract.tables[0].name, "user"); + } + + #[test] + fn standalone_named_database_is_locked_before_reset_for_the_run_lifetime() { + let temporary = tempfile::tempdir().unwrap(); + let database = temporary.path().join("shared.sqlite"); + let sentinel = b"another process owns these bytes"; + std::fs::write(&database, sentinel).unwrap(); + let external_lock = spock_host::NamedStateLock::acquire(&database).unwrap(); + let contract = + spock_lang::compile("table user { key id: uuid = auto\n username: text unique }") + .unwrap(); + + let blocked = StandaloneRun::construct(contract.clone(), Some(&database), "") + .err() + .expect("a live framework lock must block standalone reset"); + assert!(blocked.to_string().contains("already owned"), "{blocked}"); + assert_eq!(std::fs::read(&database).unwrap(), sentinel); + + drop(external_lock); + let run = StandaloneRun::construct(contract, Some(&database), "").unwrap(); + let contended = spock_host::NamedStateLock::acquire(&database) + .expect_err("standalone run must retain the lock"); + assert!( + contended.to_string().contains("already owned"), + "{contended}" + ); + + drop(run); + let released = spock_host::NamedStateLock::acquire(&database).unwrap(); + drop(released); + } +} diff --git a/crates/spock-cli/src/main.rs b/crates/spock-cli/src/main.rs index dd1f911..1d75958 100644 --- a/crates/spock-cli/src/main.rs +++ b/crates/spock-cli/src/main.rs @@ -1,16 +1,27 @@ -//! The `spock` command: `check` (diagnostics), `build` (emit the contract), -//! `gen` (derived artifacts — TypeScript types, GraphQL SDL; RFD 0010), -//! `run` (materialize + serve the HTTP protocol). docs/spec/v0.md. +//! The public `spock` command: framework projects plus the retained standalone +//! language-file tools. +use std::collections::BTreeSet; +use std::future::Future; +use std::io::{self, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::process::ExitCode; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use clap::{Parser, Subcommand}; -use spock_lang::ir::Contract; +use spock_cli::{ + CheckTargetError, CheckTargetSummary, FileProgram, GenerationTarget, StandaloneRun, +}; +use spock_host::{HostMode, HostNotice, HostNoticeSink, ProjectCheckFailure, ServeOptions}; #[derive(Parser)] -#[command(name = "spock", version, about = "The Spock v0 toolchain")] +#[command( + name = "spock", + version, + about = "The Spock framework and language toolchain" +)] struct Cli { #[command(subcommand)] command: Command, @@ -18,15 +29,51 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Parse and check a program; print diagnostics. - Check { file: PathBuf }, - /// Compile a program to its contract (JSON on stdout, or -o FILE). + /// Check a framework project, or an explicit standalone .spock file. + Check { + #[arg(value_name = "PATH")] + target: Option, + }, + /// Create a new framework project. + New { + #[arg(value_name = "NAME")] + name: String, + /// Create the required Spock backend without an Uhura client. + #[arg(long)] + backend_only: bool, + }, + /// Adopt an existing directory as a framework project. + Init { + #[arg(value_name = "PATH")] + target: Option, + }, + /// Check once and serve one fixed framework generation. + Start { + #[arg(value_name = "PATH")] + target: Option, + #[arg(long, default_value_t = 4000)] + port: u16, + /// Disposable database file, reconstructed from seed on process start. + #[arg(long)] + db: Option, + }, + /// Serve a framework project with live client publication. + Dev { + #[arg(value_name = "PATH")] + target: Option, + #[arg(long, default_value_t = 4000)] + port: u16, + /// Disposable database file, reconstructed from seed on process start. + #[arg(long)] + db: Option, + }, + /// Compile a standalone program to its contract (JSON on stdout, or -o FILE). Build { file: PathBuf, #[arg(short, long)] out: Option, }, - /// Compile, materialize the database, replay the seed, and serve HTTP. + /// Compile, materialize, seed, and serve one standalone .spock program. Run { file: PathBuf, #[arg(long, default_value_t = 4000)] @@ -35,7 +82,7 @@ enum Command { #[arg(long)] db: Option, }, - /// Generate derived artifacts from a program (RFD 0010). + /// Generate derived artifacts from a standalone program (RFD 0010). Gen { #[command(subcommand)] target: GenTarget, @@ -59,50 +106,68 @@ enum GenTarget { } fn main() -> ExitCode { - match Cli::parse().command { - Command::Check { file } => { - let Some(contract) = load(&file) else { + execute(Cli::parse().command) +} + +fn execute(command: Command) -> ExitCode { + match command { + Command::Check { target } => { + let Some(cwd) = current_dir_or_report() else { return ExitCode::FAILURE; }; - // The full load proof (RFD 0013): materialize the schema in - // memory, validate every fn body and inlined check, prove - // defaults against their checks, and replay the seed — so - // anything `spock run` would reject at load surfaces here, - // without starting a server. - if let Err(e) = spock_runtime::engine::open(&contract, None, Some(&source_dir(&file))) { - eprintln!("error: {e}"); + match spock_cli::check_target(target.as_deref(), &cwd) { + Ok(summary) => { + println!("{summary}"); + if let CheckTargetSummary::Project { report, .. } = summary { + print_project_warnings(report.warnings); + } + ExitCode::SUCCESS + } + Err(error) => report_check_error(error), + } + } + Command::New { name, backend_only } => { + let Some(cwd) = current_dir_or_report() else { return ExitCode::FAILURE; + }; + match spock_cli::create_project(&cwd, &name, backend_only) { + Ok(summary) => { + println!("{summary}"); + println!("next: run `spock dev` from the project directory above"); + ExitCode::SUCCESS + } + Err(error) => report_error(error), } - // every v0 fn statement is an SQL escape — the unchecked count - // is the ledger (RFD 0011 §4), trending to zero as native - // bodies arrive - let fns = if contract.fns.is_empty() { - "0 fn(s)".to_string() - } else { - format!( - "{} fn(s) ({} unchecked escapes)", - contract.fns.len(), - contract.fns.iter().map(|f| f.sql.len()).sum::() - ) + } + Command::Init { target } => { + let Some(cwd) = current_dir_or_report() else { + return ExitCode::FAILURE; }; - println!( - "ok: {} table(s), {} record(s), {fns}, {} seed row(s)", - contract.tables.len(), - contract.records.len(), - contract.seed.len() - ); - ExitCode::SUCCESS + match spock_cli::init_project(target.as_deref(), &cwd) { + Ok(summary) => { + println!("{summary}"); + println!("next: run `spock dev` from the project directory above"); + ExitCode::SUCCESS + } + Err(error) => report_error(error), + } + } + Command::Start { target, port, db } => { + serve_framework(target.as_deref(), port, db, HostMode::Start) + } + Command::Dev { target, port, db } => { + serve_framework(target.as_deref(), port, db, HostMode::Dev) } Command::Build { file, out } => { - let Some(contract) = load(&file) else { + let Some(program) = load_or_report(&file) else { return ExitCode::FAILURE; }; - let json = serde_json::to_string_pretty(&contract).expect("contract serializes"); + let json = spock_cli::build_artifact(program.contract()); match out { None => println!("{json}"), Some(path) => { - if let Err(e) = std::fs::write(&path, json) { - eprintln!("error: could not write {}: {e}", path.display()); + if let Err(error) = std::fs::write(&path, json) { + eprintln!("error: could not write {}: {error}", path.display()); return ExitCode::FAILURE; } println!("wrote {}", path.display()); @@ -111,15 +176,20 @@ fn main() -> ExitCode { ExitCode::SUCCESS } Command::Run { file, port, db } => { - let Some(contract) = load(&file) else { + let Some(program) = load_or_report(&file) else { return ExitCode::FAILURE; }; - match run(contract, port, db, source_dir(&file)) { + let base_dir = program.source_dir(); + let run = StandaloneRun::construct(program.into_contract(), db.as_deref(), base_dir); + match run.and_then(|run| { + let summary = run.summary(); + println!("{summary}"); + run.serve_until_ctrl_c(port, move || { + print_standalone_listening(port, summary.storage) + }) + }) { Ok(()) => ExitCode::SUCCESS, - Err(e) => { - eprintln!("error: {e}"); - ExitCode::FAILURE - } + Err(error) => report_error(error), } } Command::Gen { target } => { @@ -128,37 +198,195 @@ fn main() -> ExitCode { (file.clone(), out.clone()) } }; - let Some(contract) = load(&file) else { + let Some(program) = load_or_report(&file) else { return ExitCode::FAILURE; }; let artifact = match target { GenTarget::Types { .. } => { - spock_lang::typescript::typescript(&contract).map_err(anyhow::Error::from) + spock_cli::generate_artifact(program.contract(), GenerationTarget::Types) } - GenTarget::GraphqlSchema { .. } => graphql_sdl(contract), + GenTarget::GraphqlSchema { .. } => spock_cli::generate_artifact( + program.contract(), + GenerationTarget::GraphqlSchema, + ), }; match artifact { Ok(content) => emit(out, content), - Err(e) => { - eprintln!("error: {e}"); - ExitCode::FAILURE - } + Err(error) => report_error(error), } } } } -/// The SDL of the schema the runtime would serve — derived through the -/// same builder as `run`, so it cannot drift. The in-memory engine exists -/// only because the builder wants a full `App`; no resolver ever runs, -/// and the seed is dropped first: the SDL is a pure function of the -/// tables, so a data problem (say, a seed unique conflict) must not gate -/// a data-independent artifact. -fn graphql_sdl(mut contract: Contract) -> anyhow::Result { - contract.seed.clear(); - let conn = spock_runtime::engine::open(&contract, None, None)?; - let app = Arc::new(spock_runtime::App::new(contract, conn)); - Ok(spock_runtime::graphql::schema(app)?.sdl()) +fn serve_framework( + target: Option<&Path>, + port: u16, + database_path: Option, + mode: HostMode, +) -> ExitCode { + let Some(cwd) = current_dir_or_report() else { + return ExitCode::FAILURE; + }; + let layout = match spock_cli::resolve_project_for_serve(target, &cwd) { + Ok(layout) => layout, + Err(error) => return report_error(error), + }; + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => return report_error(error), + }; + let options = ServeOptions { + bind: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port), + database_path, + ..ServeOptions::default() + }; + let notices = HostNoticeSink::new(print_framework_notice); + let shutdown_signal = { + let _runtime_guard = runtime.enter(); + match install_framework_shutdown_signal() { + Ok(signal) => signal, + Err(error) => { + return report_error(format!( + "could not install shutdown signal handler: {error}" + )) + } + } + }; + let shutdown_error = Arc::new(Mutex::new(None)); + let shutdown_error_for_signal = Arc::clone(&shutdown_error); + let shutdown = async move { + if let Err(error) = shutdown_signal.await { + *shutdown_error_for_signal + .lock() + .expect("shutdown error lock") = Some(error); + } + }; + let result = runtime.block_on(spock_host::serve_project( + layout, mode, options, notices, shutdown, + )); + if let Some(error) = shutdown_error.lock().expect("shutdown error lock").take() { + return report_error(format!("shutdown signal handling failed: {error}")); + } + match result { + Ok(_) => ExitCode::SUCCESS, + Err(error) => report_error(error), + } +} + +type ShutdownSignal = Pin> + Send>>; + +#[cfg(unix)] +fn install_framework_shutdown_signal() -> io::Result { + use tokio::signal::unix::{signal, SignalKind}; + + let mut interrupt = signal(SignalKind::interrupt())?; + let mut terminate = signal(SignalKind::terminate())?; + Ok(Box::pin(async move { + tokio::select! { + received = interrupt.recv() => require_signal(received, "SIGINT"), + received = terminate.recv() => require_signal(received, "SIGTERM"), + } + })) +} + +#[cfg(windows)] +fn install_framework_shutdown_signal() -> io::Result { + use tokio::signal::windows::{ctrl_break, ctrl_c, ctrl_close}; + + let mut interrupt = ctrl_c()?; + let mut break_signal = ctrl_break()?; + let mut close = ctrl_close()?; + Ok(Box::pin(async move { + tokio::select! { + received = interrupt.recv() => require_signal(received, "Ctrl-C"), + received = break_signal.recv() => require_signal(received, "Ctrl-Break"), + received = close.recv() => require_signal(received, "console close"), + } + })) +} + +#[cfg(not(any(unix, windows)))] +fn install_framework_shutdown_signal() -> io::Result { + Ok(Box::pin(tokio::signal::ctrl_c())) +} + +#[cfg(any(unix, windows))] +fn require_signal(received: Option<()>, name: &str) -> io::Result<()> { + received.ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + format!("{name} signal stream closed before delivering a signal"), + ) + }) +} + +fn print_framework_notice(notice: HostNotice) { + match notice { + HostNotice::DevelopmentPolicy => eprintln!( + "warning: backend inputs (including referenced seed assets) and spock.toml topology changes are observed but not applied; restart `spock dev` to reconstruct backend state from seed" + ), + HostNotice::Listening { + address, + client_configured, + } => { + println!("listening on http://{address}"); + if client_configured { + println!(" GET / Uhura Editor"); + println!(" GET /play Uhura Play"); + } + println!(" GET /~studio Spock Studio"); + println!(" GET /~contract active Spock contract"); + println!(" GET /~project/status framework generation status"); + println!(" GET /~health aggregate readiness"); + println!(" * /rest/v1/* authority REST and RPC"); + println!(" POST /graphql/v1 GraphQL when the contract is non-empty"); + } + HostNotice::ClientBuilding { observed_revision } => { + println!("client: building revision {observed_revision}"); + } + HostNotice::ClientPublished { + observed_revision, + source_revision, + play_generation, + } => println!( + "client: published revision {observed_revision} (source {source_revision}, Play generation {play_generation})" + ), + HostNotice::ClientRejected { + observed_revision, + diagnostics, + serving_last_good, + } => { + eprintln!( + "client: rejected revision {observed_revision}; serving_last_good={serving_last_good}" + ); + for diagnostic in diagnostics { + eprintln!(" {diagnostic}"); + } + } + HostNotice::BackendRestartRequired { + changed_inputs, + diagnostics, + } => { + eprintln!( + "backend: restart required; active state remains pinned (changed: {})", + changed_inputs.join(", ") + ); + for diagnostic in diagnostics { + eprintln!(" {diagnostic}"); + } + } + HostNotice::BackendReverted => { + println!("backend: inputs match the active generation again"); + } + HostNotice::ObserverError { message } => { + eprintln!("warning: development observer: {message}"); + } + } + let _ = io::stdout().flush(); + let _ = io::stderr().flush(); } /// Print to stdout, or write to `-o FILE`. @@ -169,8 +397,8 @@ fn emit(out: Option, content: String) -> ExitCode { ExitCode::SUCCESS } Some(path) => { - if let Err(e) = std::fs::write(&path, content) { - eprintln!("error: could not write {}: {e}", path.display()); + if let Err(error) = std::fs::write(&path, content) { + eprintln!("error: could not write {}: {error}", path.display()); return ExitCode::FAILURE; } println!("wrote {}", path.display()); @@ -179,71 +407,81 @@ fn emit(out: Option, content: String) -> ExitCode { } } -/// The directory a `.spock` file lives in — the root for `file("...")` seed -/// assets (RFD 0018). Empty (cwd-relative) when the path has no parent. -fn source_dir(file: &Path) -> PathBuf { - file.parent().map(|p| p.to_path_buf()).unwrap_or_default() +fn current_dir_or_report() -> Option { + match std::env::current_dir() { + Ok(path) => Some(path), + Err(error) => { + eprintln!("error: could not resolve the working directory: {error}"); + None + } + } } -/// Read, compile, and (on failure) render every diagnostic. -fn load(path: &PathBuf) -> Option { - let source = match std::fs::read_to_string(path) { - Ok(s) => s, - Err(e) => { - eprintln!("error: could not read {}: {e}", path.display()); - return None; - } - }; - match spock_lang::compile(&source) { - Ok(contract) => Some(contract), - Err(diags) => { - for diag in &diags { - eprintln!("{}", diag.render(&source, &path.display().to_string())); - } - eprintln!( - "error: {} diagnostic(s), contract not produced", - diags.len() - ); +/// Binary-only presentation adapter for the library's structured load error. +fn load_or_report(path: &Path) -> Option { + match FileProgram::load(path) { + Ok(program) => Some(program), + Err(error) => { + eprintln!("{error}"); None } } } -fn run( - contract: Contract, - port: u16, - db: Option, - base_dir: PathBuf, -) -> anyhow::Result<()> { - let conn = spock_runtime::engine::open(&contract, db.as_deref(), Some(base_dir.as_path()))?; - println!( - "spock v0 — contract loaded: {} table(s), {} fn(s), {} seed row(s) replayed", - contract.tables.len(), - contract.fns.len(), - contract.seed.len() - ); - - let app = Arc::new(spock_runtime::App::new(contract, conn)); - let storage = spock_runtime::storage::storage_active(&app.contract); - let runtime = tokio::runtime::Runtime::new()?; - runtime.block_on(async move { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?; - println!("listening on http://127.0.0.1:{port}"); - println!(" GET /~studio the developer console — browse, impersonate, run"); - println!(" GET /~contract the contract, as data"); - println!(" GET /rest/v1/{{table}} open reads (identity view)"); - println!(" POST /rest/v1/rpc/{{fn}} call a declared fn"); - println!(" POST /graphql/v1 GraphQL reads + writes (GraphiQL in the browser)"); - if storage { - println!(" * /storage/v1/object upload + serve files (signed URLs)"); - } - // `serve` owns the in-process orphan sweep for a storage contract - // (RFD 0018 §1.6); the binary only decides when to stop. - tokio::select! { - result = spock_runtime::http::serve(app.clone(), listener) => result?, - _ = tokio::signal::ctrl_c() => {} +fn report_error(error: impl std::fmt::Display) -> ExitCode { + eprintln!("error: {error}"); + ExitCode::FAILURE +} + +fn report_check_error(error: CheckTargetError) -> ExitCode { + match error { + // These two preserve the historical standalone rendering, which + // already owns its `error:` prefix and source diagnostics. + CheckTargetError::FileLoad(error) => eprintln!("{error}"), + CheckTargetError::FileLoadProof { source, .. } => eprintln!("error: {source}"), + CheckTargetError::ProjectCheck(error) => report_project_check_failure(&error), + error => eprintln!("error: {error}"), + } + ExitCode::FAILURE +} + +fn print_project_warnings(warnings: Vec) { + let mut seen = BTreeSet::new(); + for warning in warnings { + if seen.insert(warning.clone()) { + eprintln!("warning: {warning}"); + } + } +} + +fn report_project_check_failure(error: &ProjectCheckFailure) { + let mut seen = BTreeSet::new(); + let mut first = true; + for diagnostic in error.diagnostics() { + if !seen.insert(diagnostic.clone()) { + continue; } - Ok::<(), anyhow::Error>(()) - })?; - Ok(()) + let rendered = diagnostic.to_string(); + if first { + eprintln!("error: {rendered}"); + first = false; + } else { + eprintln!("{rendered}"); + } + } + if first { + eprintln!("error: project check failed without diagnostics"); + } +} + +fn print_standalone_listening(port: u16, storage: bool) { + println!("listening on http://127.0.0.1:{port}"); + println!(" GET /~studio the developer console — browse, impersonate, run"); + println!(" GET /~contract the contract, as data"); + println!(" GET /rest/v1/{{table}} open reads (identity view)"); + println!(" POST /rest/v1/rpc/{{fn}} call a declared fn"); + println!(" POST /graphql/v1 GraphQL reads + writes (GraphiQL in the browser)"); + if storage { + println!(" * /storage/v1/object upload + serve files (signed URLs)"); + } } diff --git a/crates/spock-cli/src/project_commands.rs b/crates/spock-cli/src/project_commands.rs new file mode 100644 index 0000000..b2846cf --- /dev/null +++ b/crates/spock-cli/src/project_commands.rs @@ -0,0 +1,635 @@ +//! Reusable command boundaries for framework-project operations. +//! +//! Target selection, checking, and filesystem mutation live here rather than +//! in the Clap presentation layer. An explicit `.spock` target keeps the +//! historical language-file check; every other accepted check target resolves +//! to a validated framework project. + +use std::collections::BTreeSet; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +use spock_host::{ProjectCheckFailure, ProjectCheckReport}; +use spock_project::{ + adoption_plan, load_project, minimal_uhura_client_template, parse_manifest, resolve_target, + scaffold_plan, Diagnostics, NormalizedRelativePath, ProjectInventory, ProjectLayout, + ProjectName, ResolvedTarget, MANIFEST_FILE, +}; +use thiserror::Error; + +use crate::write_plan::{apply_prepared_write_plan, PreparedWriteRoot, PreparedWriteTarget}; +use crate::{full_load_check, ApplyError, CheckSummary, FileProgram, ProgramLoadError}; + +/// A successful polymorphic `spock check` result. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CheckTargetSummary { + /// The historical one-file language/runtime proof. + File { + path: PathBuf, + summary: CheckSummary, + }, + /// A manifest, backend, client, and currently provable link check. + Project { + root: PathBuf, + project_name: String, + report: ProjectCheckReport, + }, +} + +impl CheckTargetSummary { + #[must_use] + pub fn path(&self) -> &Path { + match self { + Self::File { path, .. } => path, + Self::Project { root, .. } => root, + } + } +} + +impl fmt::Display for CheckTargetSummary { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::File { summary, .. } => summary.fmt(formatter), + Self::Project { + project_name, + report, + .. + } => { + write!( + formatter, + "ok: project `{project_name}` — {} table(s), {} record(s), {} fn(s), {} seed row(s)", + report.backend.tables, + report.backend.records, + report.backend.functions, + report.backend.seed_rows, + )?; + match &report.client { + Some(client) => write!( + formatter, + ", {} preview(s), {} replay-derived preview(s)", + client.preview_count, client.replay_derived_count, + )?, + None => formatter.write_str(", backend only")?, + } + write!( + formatter, + ", {} unchecked link(s), {} warning(s)", + report.unchecked_links, + report.warnings.len(), + ) + } + } + } +} + +/// Failure from polymorphic `spock check` target selection or checking. +#[derive(Debug, Error)] +pub enum CheckTargetError { + #[error(transparent)] + ProjectTopology(#[from] Diagnostics), + #[error(transparent)] + FileLoad(#[from] ProgramLoadError), + #[error("error: {source}")] + FileLoadProof { + path: PathBuf, + #[source] + source: anyhow::Error, + }, + #[error(transparent)] + ProjectCheck(#[from] ProjectCheckFailure), +} + +/// Select and fully check either an explicit `.spock` file or a project. +/// +/// File mode deliberately calls the same [`FileProgram`] and +/// [`full_load_check`] boundary used by the legacy CLI. In project mode the +/// framework manifest is loaded first, then every configured subsystem is +/// checked without binding a listener or touching named state. +pub fn check_target( + target: Option<&Path>, + cwd: &Path, +) -> Result { + if let Some(path) = target.filter(|path| has_spock_extension(path)) { + let program = FileProgram::load_from_cwd(path, cwd)?; + let summary = + full_load_check(program.contract(), program.source_dir()).map_err(|source| { + CheckTargetError::FileLoadProof { + path: path.to_path_buf(), + source, + } + })?; + return Ok(CheckTargetSummary::File { + path: path.to_path_buf(), + summary, + }); + } + + match resolve_target(target, cwd)? { + ResolvedTarget::SpockFile(path) => { + // `resolve_target` selects file mode only by the extension checked + // above. Keep this fallback defensive for future resolver modes. + let program = FileProgram::load(&path)?; + let summary = + full_load_check(program.contract(), program.source_dir()).map_err(|source| { + CheckTargetError::FileLoadProof { + path: path.clone(), + source, + } + })?; + Ok(CheckTargetSummary::File { path, summary }) + } + ResolvedTarget::Project(root) => { + let layout = load_project(&root)?; + let project_name = layout.manifest.project().as_str().to_string(); + let mut report = spock_host::check_project(&layout)?; + deduplicate_warnings(&mut report); + Ok(CheckTargetSummary::Project { + root: layout.root, + project_name, + report, + }) + } + } +} + +/// Failure while selecting a framework project for `start` or `dev`. +#[derive(Debug, Error)] +pub enum ResolveProjectForServeError { + #[error(transparent)] + ProjectTopology(#[from] Diagnostics), + #[error("`{}` selects standalone `.spock` file mode; run `spock run` with that file instead", path.display())] + StandaloneFile { path: PathBuf }, +} + +/// Resolve and validate a project target for a framework serving command. +/// +/// `start` and `dev` never silently reinterpret a `.spock` file as a project; +/// their error points at the retained standalone `spock run` escape hatch. +pub fn resolve_project_for_serve( + target: Option<&Path>, + cwd: &Path, +) -> Result { + if let Some(path) = target.filter(|path| has_spock_extension(path)) { + return Err(ResolveProjectForServeError::StandaloneFile { + path: path.to_path_buf(), + }); + } + + match resolve_target(target, cwd)? { + ResolvedTarget::Project(root) => Ok(load_project(&root)?), + ResolvedTarget::SpockFile(path) => { + Err(ResolveProjectForServeError::StandaloneFile { path }) + } + } +} + +fn has_spock_extension(path: &Path) -> bool { + path.extension().and_then(|extension| extension.to_str()) == Some("spock") +} + +fn deduplicate_warnings(report: &mut ProjectCheckReport) { + let mut seen = BTreeSet::new(); + report + .warnings + .retain(|warning| seen.insert(warning.clone())); +} + +/// Whether a successful project write created a new root or adopted one. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ProjectWriteOperation { + New, + Init, +} + +/// Exact, presentation-independent effects of `spock new` or `spock init`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectWriteSummary { + pub operation: ProjectWriteOperation, + pub root: PathBuf, + pub project_name: String, + pub includes_client: bool, + pub created_files: Vec, + pub created_directories: Vec, +} + +impl fmt::Display for ProjectWriteSummary { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let verb = match self.operation { + ProjectWriteOperation::New => "created", + ProjectWriteOperation::Init => "initialized", + }; + let shape = if self.includes_client { + "full-stack" + } else { + "backend-only" + }; + write!( + formatter, + "{verb} {shape} project `{}` at {}", + self.project_name, + self.root.display(), + ) + } +} + +/// A rejected `spock new NAME` before any path is created. +#[derive(Clone, Debug, Eq, Error, PartialEq)] +#[error("invalid project name `{name}`: {reason}; NAME must be one safe path component")] +pub struct NewProjectNameError { + pub name: String, + pub reason: String, +} + +/// Failure while planning or safely applying `spock new`/`spock init`. +#[derive(Debug, Error)] +pub enum ProjectWriteError { + #[error(transparent)] + InvalidName(#[from] NewProjectNameError), + #[error("could not resolve {role} `{}`: {source}", path.display())] + ResolveDirectory { + role: &'static str, + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("{role} `{}` is not a directory", path.display())] + NotDirectory { role: &'static str, path: PathBuf }, + #[error(transparent)] + ProjectPlan(#[from] Diagnostics), + #[error(transparent)] + Apply(#[from] ApplyError), +} + +/// Create the canonical project as a direct child of `cwd`. +/// +/// Full stack is the default; `backend_only` is the explicit opt-out. The +/// destination is preflighted as empty and then created with the race-safe +/// new-destination policy. +pub fn create_project( + cwd: &Path, + name: &str, + backend_only: bool, +) -> Result { + validate_new_project_name(name)?; + let cwd = canonical_directory(cwd, "working directory")?; + let prepared_parent = prepare_write_root(&cwd, "working directory")?; + let destination = prepared_parent.path().join(name); + let client = (!backend_only).then(minimal_uhura_client_template); + let plan = scaffold_plan(&destination, name, client.as_ref())?; + let inventory = ProjectInventory::empty(destination.clone()); + plan.preflight(&inventory)?; + apply_project_plan( + &plan, + PreparedWriteTarget::new_child(prepared_parent, name), + ProjectWriteOperation::New, + ) +} + +/// Adopt exactly `target`, or `cwd` when no target is supplied. +/// +/// Unlike project discovery, initialization does not walk to a parent +/// manifest: it inventories the selected directory itself and lets the pure +/// adoption planner reject existing projects, ambiguity, and conflicts before +/// the race-safe apply boundary runs. +pub fn init_project( + target: Option<&Path>, + cwd: &Path, +) -> Result { + let cwd = canonical_directory(cwd, "working directory")?; + let selected = match target { + Some(path) if path.is_absolute() => path.to_path_buf(), + Some(path) => cwd.join(path), + None => cwd, + }; + let selected = canonical_directory(&selected, "adoption root")?; + let prepared_root = prepare_write_root(&selected, "adoption root")?; + let inventory = prepared_root.inventory()?; + prepared_root + .validate() + .map_err(|source| ProjectWriteError::ResolveDirectory { + role: "adoption root", + path: selected, + source, + })?; + let plan = adoption_plan(&inventory, None)?; + plan.preflight(&inventory)?; + apply_project_plan( + &plan, + PreparedWriteTarget::existing(prepared_root), + ProjectWriteOperation::Init, + ) +} + +fn validate_new_project_name(name: &str) -> Result<(), NewProjectNameError> { + ProjectName::parse(name).map_err(|reason| NewProjectNameError { + name: name.to_string(), + reason, + })?; + + let path = NormalizedRelativePath::file(name).map_err(|error| NewProjectNameError { + name: name.to_string(), + reason: error.to_string(), + })?; + if !path.parent().is_project_root() { + return Err(NewProjectNameError { + name: name.to_string(), + reason: "nested paths are not allowed".to_string(), + }); + } + Ok(()) +} + +fn canonical_directory(path: &Path, role: &'static str) -> Result { + let canonical = + fs::canonicalize(path).map_err(|source| ProjectWriteError::ResolveDirectory { + role, + path: path.to_path_buf(), + source, + })?; + if !canonical.is_dir() { + return Err(ProjectWriteError::NotDirectory { + role, + path: canonical, + }); + } + Ok(canonical) +} + +fn prepare_write_root( + path: &Path, + role: &'static str, +) -> Result { + PreparedWriteRoot::open(path).map_err(|source| ProjectWriteError::ResolveDirectory { + role, + path: path.to_path_buf(), + source, + }) +} + +fn apply_project_plan( + plan: &spock_project::WritePlan, + target: PreparedWriteTarget, + operation: ProjectWriteOperation, +) -> Result { + let manifest_write = plan + .write(MANIFEST_FILE) + .expect("project plans always include their manifest commit marker"); + let manifest_source = std::str::from_utf8(&manifest_write.contents) + .expect("project planners emit a UTF-8 manifest"); + let manifest = parse_manifest(manifest_source)?; + let project_name = manifest.project().as_str().to_string(); + let includes_client = manifest.client().is_some(); + + let applied = apply_prepared_write_plan(plan, target)?; + Ok(ProjectWriteSummary { + operation, + root: applied.root().to_path_buf(), + project_name, + includes_client, + created_files: applied.created_files().to_vec(), + created_directories: applied.created_directories().to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + loop { + let id = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "spock-project-commands-{}-{id}", + std::process::id() + )); + match fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("could not create test directory: {error}"), + } + } + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn explicit_spock_check_keeps_the_file_load_proof_and_summary() { + let temporary = TestDirectory::new(); + let source = temporary.path().join("standalone.spock"); + fs::write( + &source, + "table user { key id: uuid = auto\n name: text }\n\ + seed { user { name: \"Ada\" } }\n", + ) + .unwrap(); + + let result = check_target(Some(Path::new("standalone.spock")), temporary.path()).unwrap(); + + assert_eq!( + result, + CheckTargetSummary::File { + path: PathBuf::from("standalone.spock"), + summary: CheckSummary { + tables: 1, + records: 0, + functions: 0, + unchecked_escapes: 0, + seed_rows: 1, + }, + } + ); + assert_eq!( + result.to_string(), + "ok: 1 table(s), 0 record(s), 0 fn(s), 1 seed row(s)" + ); + } + + #[test] + fn omitted_check_target_checks_the_nearest_project() { + let temporary = TestDirectory::new(); + let created = create_project(temporary.path(), "demo", true).unwrap(); + let nested = created.root.join("backend"); + + let checked = check_target(None, &nested).unwrap(); + + let CheckTargetSummary::Project { + root, + project_name, + report, + } = checked + else { + panic!("omitted target did not select project mode"); + }; + assert_eq!(root, created.root); + assert_eq!(project_name, "demo"); + assert_eq!(report.backend.tables, 0); + assert!(report.client.is_none()); + assert!(report.warnings.is_empty()); + } + + #[test] + fn framework_serve_rejects_file_mode_with_run_guidance() { + let temporary = TestDirectory::new(); + let error = + resolve_project_for_serve(Some(Path::new("api.spock")), temporary.path()).unwrap_err(); + + assert!(matches!( + error, + ResolveProjectForServeError::StandaloneFile { .. } + )); + let rendered = error.to_string(); + assert!(rendered.contains("spock run"), "{rendered}"); + assert!(rendered.contains("api.spock"), "{rendered}"); + } + + #[test] + fn new_defaults_to_the_embedded_full_stack_template() { + let temporary = TestDirectory::new(); + + let summary = create_project(temporary.path(), "demo", false).unwrap(); + + assert_eq!(summary.operation, ProjectWriteOperation::New); + assert_eq!(summary.project_name, "demo"); + assert!(summary.includes_client); + assert!(summary.root.join("backend/app.spock").is_file()); + for file in minimal_uhura_client_template().files() { + assert_eq!( + fs::read(summary.root.join("client").join(file.path().as_path())).unwrap(), + file.contents(), + ); + } + assert_eq!( + summary.created_files.last(), + Some(&summary.root.join(MANIFEST_FILE)), + "manifest remains the final commit marker" + ); + } + + #[test] + fn backend_only_new_omits_client_topology() { + let temporary = TestDirectory::new(); + + let summary = create_project(temporary.path(), "authority", true).unwrap(); + + assert!(!summary.includes_client); + assert!(!summary.root.join("client").exists()); + let manifest = fs::read_to_string(summary.root.join(MANIFEST_FILE)).unwrap(); + assert!(!manifest.contains("[client]")); + } + + #[test] + fn unsafe_new_names_fail_without_filesystem_effects() { + let temporary = TestDirectory::new(); + + for name in [ + "", + ".", + "..", + "../escape", + "nested/name", + "nested\\name", + "C:", + ] { + let error = create_project(temporary.path(), name, false).unwrap_err(); + assert!(matches!(error, ProjectWriteError::InvalidName(_)), "{name}"); + } + + assert_eq!(fs::read_dir(temporary.path()).unwrap().count(), 0); + } + + #[test] + fn init_adopts_the_exact_selected_directory_without_moving_sources() { + let temporary = TestDirectory::new(); + let adoption = temporary.path().join("existing"); + fs::create_dir(&adoption).unwrap(); + fs::write(adoption.join("main.spock"), "").unwrap(); + fs::write(adoption.join("owned.txt"), "keep\n").unwrap(); + + let summary = init_project(Some(Path::new("existing")), temporary.path()).unwrap(); + + assert_eq!(summary.operation, ProjectWriteOperation::Init); + assert_eq!(summary.root, fs::canonicalize(&adoption).unwrap()); + assert_eq!( + fs::read_to_string(adoption.join("owned.txt")).unwrap(), + "keep\n" + ); + assert!(adoption.join("main.spock").is_file()); + assert!(!adoption.join("backend/app.spock").exists()); + let manifest = fs::read_to_string(adoption.join(MANIFEST_FILE)).unwrap(); + assert!(manifest.contains("root = \".\""), "{manifest}"); + assert!(manifest.contains("entry = \"main.spock\""), "{manifest}"); + } + + #[test] + fn init_of_uhura_only_root_adds_the_required_empty_backend_and_checks() { + let temporary = TestDirectory::new(); + for file in minimal_uhura_client_template().files() { + let destination = temporary.path().join(file.path().as_path()); + fs::create_dir_all(destination.parent().unwrap()).unwrap(); + fs::write(destination, file.contents()).unwrap(); + } + + let summary = init_project(None, temporary.path()).unwrap(); + + assert!(summary.includes_client); + assert!(temporary.path().join("backend/app.spock").is_file()); + let checked = check_target(None, temporary.path()).unwrap(); + let CheckTargetSummary::Project { report, .. } = checked else { + panic!("adopted root did not select project mode"); + }; + assert!(report.client.is_some()); + assert_eq!(report.unchecked_links, 1); + assert_eq!(report.warnings.len(), 1); + } + + #[test] + fn init_ambiguity_fails_before_writing_a_manifest() { + let temporary = TestDirectory::new(); + fs::write(temporary.path().join("one.spock"), "").unwrap(); + fs::write(temporary.path().join("two.spock"), "").unwrap(); + + let error = init_project(None, temporary.path()).unwrap_err(); + + let ProjectWriteError::ProjectPlan(diagnostics) = error else { + panic!("ambiguity did not remain a structured project diagnostic"); + }; + assert_eq!( + diagnostics.iter().next().map(|diagnostic| diagnostic.code), + Some(spock_project::DiagnosticCode::AmbiguousBackend), + ); + assert!(!temporary.path().join(MANIFEST_FILE).exists()); + } + + #[test] + fn init_rejects_portable_manifest_alias_before_other_writes() { + let temporary = TestDirectory::new(); + let aliased_manifest = temporary.path().join("SPOCK.TOML"); + fs::write(&aliased_manifest, "owned-by-existing-project\n").unwrap(); + + let error = init_project(None, temporary.path()).unwrap_err(); + + assert!(matches!(error, ProjectWriteError::ProjectPlan(_))); + assert_eq!( + fs::read_to_string(aliased_manifest).unwrap(), + "owned-by-existing-project\n" + ); + assert!(!temporary.path().join("backend").exists()); + } +} diff --git a/crates/spock-cli/src/write_plan.rs b/crates/spock-cli/src/write_plan.rs new file mode 100644 index 0000000..f910894 --- /dev/null +++ b/crates/spock-cli/src/write_plan.rs @@ -0,0 +1,2771 @@ +//! Race-safe filesystem application for [`spock_project::WritePlan`]. +//! +//! Planning remains mutation-free in `spock-project`. This module owns the +//! imperative boundary used by `spock new` and `spock init`: it creates every +//! file with `create_new`, treats `spock.toml` as the final commit marker, and +//! handles failure without touching pre-existing paths. Unix rollback removes +//! exact invocation-owned files through retained parent handles but preserves +//! created directories because `mkdir` cannot atomically return an ownership +//! handle. Windows deliberately preserves and reports every known creation +//! because the available rename APIs cannot safely restore without replacement. + +#[cfg(windows)] +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fs; +#[cfg(not(unix))] +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +#[cfg(not(windows))] +use std::sync::atomic::{AtomicU64, Ordering}; + +use spock_project::{ + is_ignored_inventory_directory, Diagnostic, DiagnosticCode, Diagnostics, InventoryEntryKind, + NormalizedRelativePath, PlanKind, ProjectInventory, ProjectResult, WritePlan, MANIFEST_FILE, +}; + +#[cfg(not(windows))] +static NEXT_QUARANTINE: AtomicU64 = AtomicU64::new(0); + +/// Filesystem policy for the root of a write plan. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RootPolicy { + /// `spock new`: the destination itself must not exist. + NewDestination, + /// `spock init`: the adoption root must already be a real directory. + ExistingAdoptionRoot, +} + +impl RootPolicy { + fn accepts(self, kind: PlanKind) -> bool { + matches!( + (self, kind), + (Self::NewDestination, PlanKind::Scaffold) + | (Self::ExistingAdoptionRoot, PlanKind::Adopt) + ) + } +} + +/// A live filesystem capability retained between project inventory and apply. +/// +/// The public project commands use this lease so planning and mutation cannot +/// silently select different directories at the same pathname. On Windows the +/// retained directory handle also denies delete sharing, preventing rename or +/// replacement for the lifetime of the lease. +#[derive(Debug)] +pub(crate) struct PreparedWriteRoot(PinnedRoot); + +impl PreparedWriteRoot { + pub(crate) fn open(path: &Path) -> io::Result { + PinnedRoot::open(path).map(Self) + } + + pub(crate) fn path(&self) -> &Path { + &self.0.path + } + + pub(crate) fn validate(&self) -> io::Result<()> { + self.0.validate() + } + + /// Inventory exactly the retained directory used by the later apply. + pub(crate) fn inventory(&self) -> ProjectResult { + scan_prepared_inventory(&self.0) + } +} + +fn inventory_io( + root: &Path, + relative: &Path, + action: &str, + error: impl fmt::Display, +) -> Diagnostics { + Diagnostics::one( + Diagnostic::new(DiagnosticCode::Io, format!("could not {action}: {error}")) + .at_path(root.join(relative)), + ) +} + +fn normalized_inventory_path( + root: &Path, + relative: &Path, +) -> ProjectResult { + let mut segments = Vec::new(); + for component in relative.components() { + let std::path::Component::Normal(segment) = component else { + return Err(Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + "scanned path cannot be represented in a project manifest", + ) + .at_path(root.join(relative)) + .into()); + }; + let Some(segment) = segment.to_str() else { + return Err(Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + "scanned path is not UTF-8 and cannot be represented in a project manifest", + ) + .at_path(root.join(relative)) + .into()); + }; + segments.push(segment); + } + NormalizedRelativePath::file(&segments.join("/")).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + format!("scanned path is not a valid project path: {error}"), + ) + .at_path(root.join(relative)), + ) + }) +} + +#[cfg(unix)] +fn scan_prepared_inventory(root: &PinnedRoot) -> ProjectResult { + let mut entries = Vec::new(); + scan_unix_inventory_directory(&root.directory, Path::new(""), &root.path, &mut entries)?; + ProjectInventory::from_entries(root.path.clone(), entries) +} + +#[cfg(unix)] +fn scan_unix_inventory_directory( + directory: &fs::File, + relative_parent: &Path, + root: &Path, + entries: &mut Vec<(NormalizedRelativePath, InventoryEntryKind)>, +) -> ProjectResult<()> { + use std::os::unix::ffi::OsStrExt; + + let reader = rustix::fs::Dir::read_from(directory) + .map_err(|error| inventory_io(root, relative_parent, "scan directory", error))?; + let mut names = reader + .map(|entry| { + let entry = entry.map_err(|error| { + inventory_io(root, relative_parent, "scan directory entry", error) + })?; + Ok(OsStr::from_bytes(entry.file_name().to_bytes()).to_os_string()) + }) + .collect::>>()?; + names.retain(|name| name != "." && name != ".."); + names.sort(); + + for name in names { + let relative = relative_parent.join(&name); + let metadata = rustix::fs::statat(directory, &name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW) + .map_err(|error| inventory_io(root, &relative, "inspect directory entry", error))?; + let kind = match rustix::fs::FileType::from_raw_mode(metadata.st_mode) { + rustix::fs::FileType::Directory => InventoryEntryKind::Directory, + rustix::fs::FileType::Symlink => InventoryEntryKind::Symlink, + rustix::fs::FileType::RegularFile => InventoryEntryKind::File, + _ => InventoryEntryKind::Unsupported, + }; + let normalized = normalized_inventory_path(root, &relative)?; + let ignored = kind == InventoryEntryKind::Directory + && normalized + .file_name() + .is_some_and(is_ignored_inventory_directory); + entries.push((normalized, kind)); + if kind == InventoryEntryKind::Directory && !ignored { + let child = open_directory_at(directory, &name) + .map_err(|error| inventory_io(root, &relative, "open directory", error))?; + scan_unix_inventory_directory(&child, &relative, root, entries)?; + } + } + Ok(()) +} + +#[cfg(windows)] +fn scan_prepared_inventory(root: &PinnedRoot) -> ProjectResult { + let mut entries = Vec::new(); + scan_windows_inventory_directory(&root.directory, Path::new(""), &root.path, &mut entries)?; + ProjectInventory::from_entries(root.path.clone(), entries) +} + +#[cfg(windows)] +fn scan_windows_inventory_directory( + directory: &cap_std::fs::Dir, + relative_parent: &Path, + root: &Path, + entries: &mut Vec<(NormalizedRelativePath, InventoryEntryKind)>, +) -> ProjectResult<()> { + use cap_fs_ext::DirExt as _; + + let reader = directory + .entries() + .map_err(|error| inventory_io(root, relative_parent, "scan directory", error))?; + let mut children = reader + .map(|entry| { + let entry = entry.map_err(|error| { + inventory_io(root, relative_parent, "scan directory entry", error) + })?; + let name = entry.file_name(); + let kind = entry.file_type().map_err(|error| { + inventory_io( + root, + &relative_parent.join(&name), + "inspect directory entry", + error, + ) + })?; + Ok((name, kind)) + }) + .collect::>>()?; + children.sort_by(|left, right| left.0.cmp(&right.0)); + + for (name, file_type) in children { + let relative = relative_parent.join(&name); + let kind = if file_type.is_symlink() { + InventoryEntryKind::Symlink + } else if file_type.is_dir() { + InventoryEntryKind::Directory + } else if file_type.is_file() { + InventoryEntryKind::File + } else { + InventoryEntryKind::Unsupported + }; + let normalized = normalized_inventory_path(root, &relative)?; + let ignored = kind == InventoryEntryKind::Directory + && normalized + .file_name() + .is_some_and(is_ignored_inventory_directory); + entries.push((normalized, kind)); + if kind == InventoryEntryKind::Directory && !ignored { + let child = directory + .open_dir_nofollow(&name) + .map_err(|error| inventory_io(root, &relative, "open directory", error))?; + scan_windows_inventory_directory(&child, &relative, root, entries)?; + } + } + Ok(()) +} + +#[cfg(not(any(unix, windows)))] +fn scan_prepared_inventory(root: &PinnedRoot) -> ProjectResult { + root.validate().map_err(|error| { + inventory_io(&root.path, Path::new(""), "validate adoption root", error) + })?; + ProjectInventory::scan(&root.path) +} + +#[derive(Debug)] +pub(crate) enum PreparedWriteTarget { + NewChild { + parent: PreparedWriteRoot, + child: OsString, + }, + Existing(PreparedWriteRoot), +} + +impl PreparedWriteTarget { + pub(crate) fn new_child(parent: PreparedWriteRoot, child: impl Into) -> Self { + Self::NewChild { + parent, + child: child.into(), + } + } + + pub(crate) fn existing(root: PreparedWriteRoot) -> Self { + Self::Existing(root) + } + + fn policy(&self) -> RootPolicy { + match self { + Self::NewChild { .. } => RootPolicy::NewDestination, + Self::Existing(_) => RootPolicy::ExistingAdoptionRoot, + } + } +} + +/// The operation that failed while applying a plan. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ApplyStage { + ValidatePolicy, + ValidateRoot, + CreateRoot, + CreateDirectory, + CreateFile, + RecordOwnership, + WriteFile, +} + +impl fmt::Display for ApplyStage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let description = match self { + Self::ValidatePolicy => "validate write-plan policy", + Self::ValidateRoot => "validate project root", + Self::CreateRoot => "create project root", + Self::CreateDirectory => "create project directory", + Self::CreateFile => "create project file", + Self::RecordOwnership => "record project path ownership", + Self::WriteFile => "write project file", + }; + f.write_str(description) + } +} + +/// Kind of invocation-created path considered during rollback. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum CreatedPathKind { + File, + Directory, +} + +/// One invocation-created path that could not be removed during rollback. +#[derive(Debug)] +pub struct RollbackResidual { + path: PathBuf, + kind: CreatedPathKind, + error: io::Error, +} + +impl RollbackResidual { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn kind(&self) -> CreatedPathKind { + self.kind + } + + pub fn error(&self) -> &io::Error { + &self.error + } +} + +/// Result of the best-effort rollback performed after an apply failure. +#[derive(Debug, Default)] +pub struct RollbackReport { + residuals: Vec, +} + +impl RollbackReport { + /// True when every invocation-created path is gone. + pub fn is_complete(&self) -> bool { + self.residuals.is_empty() + } + + /// Invocation-created paths that still exist or could not be inspected. + pub fn residuals(&self) -> &[RollbackResidual] { + &self.residuals + } +} + +/// Successful filesystem effects of one plan application. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApplySummary { + root: PathBuf, + created_files: Vec, + created_directories: Vec, +} + +impl ApplySummary { + pub fn root(&self) -> &Path { + &self.root + } + + /// Files in creation order. `spock.toml` is always last. + pub fn created_files(&self) -> &[PathBuf] { + &self.created_files + } + + /// Directories in parent-before-child creation order. + pub fn created_directories(&self) -> &[PathBuf] { + &self.created_directories + } +} + +/// A failed plan application together with its rollback outcome. +#[derive(Debug)] +pub struct ApplyError { + stage: ApplyStage, + path: PathBuf, + source: io::Error, + rollback: RollbackReport, +} + +impl ApplyError { + pub fn stage(&self) -> ApplyStage { + self.stage + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn io_error(&self) -> &io::Error { + &self.source + } + + pub fn rollback(&self) -> &RollbackReport { + &self.rollback + } +} + +impl fmt::Display for ApplyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "could not {} `{}`: {}", + self.stage, + self.path.display(), + self.source + )?; + if !self.rollback.is_complete() { + write!(f, "; rollback left")?; + for residual in self.rollback.residuals() { + write!(f, " `{}` ({})", residual.path().display(), residual.error())?; + } + } + Ok(()) + } +} + +impl std::error::Error for ApplyError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +/// Apply a pure project write plan without overwriting any existing file. +/// +/// Non-manifest files are created first. `spock.toml` is created last so its +/// presence means all preceding template writes succeeded. A later conflict +/// triggers a platform-specific best-effort rollback. Unix removes exact +/// invocation-owned files through retained parent handles and reports created +/// directories as residuals. Windows performs no rollback mutation and reports +/// every known creation as a residual. These conservative rules avoid deleting +/// or overwriting an entry concurrently installed under a created name. +#[cfg(test)] +fn apply_write_plan(plan: &WritePlan, root_policy: RootPolicy) -> Result { + apply_write_plan_inner(plan, root_policy, |_| {}) +} + +pub(crate) fn apply_prepared_write_plan( + plan: &WritePlan, + target: PreparedWriteTarget, +) -> Result { + let policy = target.policy(); + apply_write_plan_inner_with_target(plan, policy, Some(target), |_| {}) +} + +#[cfg(test)] +fn apply_write_plan_inner( + plan: &WritePlan, + root_policy: RootPolicy, + after_write: F, +) -> Result +where + F: FnMut(&Path), +{ + apply_write_plan_inner_with_target(plan, root_policy, None, after_write) +} + +fn apply_write_plan_inner_with_target( + plan: &WritePlan, + root_policy: RootPolicy, + prepared_target: Option, + mut after_write: F, +) -> Result +where + F: FnMut(&Path), +{ + let mut journal = CreationJournal::default(); + + if !root_policy.accepts(plan.kind) { + let source = io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "root policy {root_policy:?} does not accept a {:?} plan", + plan.kind + ), + ); + return Err(failed( + ApplyStage::ValidatePolicy, + &plan.root, + source, + journal, + )); + } + + let root = match prepare_root(&plan.root, root_policy, prepared_target, &mut journal) { + Ok(root) => root, + Err(failure) => { + return Err(failed(failure.stage, failure.path, failure.error, journal)); + } + }; + let mut directory_cache = DirectoryCache::new(); + + let mut writes = plan.writes().iter().collect::>(); + writes.sort_by(|left, right| { + let left_is_manifest = left.relative_path.as_str() == MANIFEST_FILE; + let right_is_manifest = right.relative_path.as_str() == MANIFEST_FILE; + left_is_manifest + .cmp(&right_is_manifest) + .then_with(|| left.relative_path.cmp(&right.relative_path)) + }); + + for write in writes { + let relative_parent = write + .relative_path + .as_path() + .parent() + .expect("a planned file always has a parent"); + let parent = match ensure_relative_directories( + &root, + relative_parent, + &mut journal, + &mut directory_cache, + ) { + Ok(parent) => parent, + Err(failure) => { + return Err(failed(failure.stage, failure.path, failure.error, journal)); + } + }; + + let destination = plan.root.join(write.relative_path.as_path()); + let file_name = write + .relative_path + .as_path() + .file_name() + .expect("a planned file has a file name"); + let (mut file, anchor) = match parent.create_new_file(file_name) { + Ok(created) => created, + Err(error) => { + return Err(failed(ApplyStage::CreateFile, destination, error, journal)); + } + }; + // Retain an open handle until success or rollback. Comparing a current + // directory entry to this live file object avoids inode/file-index + // reuse after an editor atomically replaces the path. + let identity = match journal_identity_from_file(&file) { + Ok(identity) => identity, + Err(error) => { + journal + .files + .push(JournalEntry::file(destination.clone(), file, None, anchor)); + return Err(failed( + ApplyStage::RecordOwnership, + destination, + error, + journal, + )); + } + }; + + if let Err(error) = file.write_all(&write.contents) { + journal.files.push(JournalEntry::file( + destination.clone(), + file, + identity, + anchor, + )); + return Err(failed(ApplyStage::WriteFile, destination, error, journal)); + } + journal.files.push(JournalEntry::file( + destination.clone(), + file, + identity, + anchor, + )); + after_write(&destination); + } + + if let Err(error) = root.validate() { + return Err(failed(ApplyStage::ValidateRoot, &plan.root, error, journal)); + } + + let CreationJournal { files, directories } = journal; + Ok(ApplySummary { + root: plan.root.clone(), + created_files: files.into_iter().map(|entry| entry.path).collect(), + created_directories: directories.into_iter().map(|entry| entry.path).collect(), + }) +} + +#[derive(Debug)] +struct OperationFailure { + stage: ApplyStage, + path: PathBuf, + error: io::Error, +} + +fn prepare_root( + root: &Path, + root_policy: RootPolicy, + prepared_target: Option, + journal: &mut CreationJournal, +) -> Result { + if let Some(prepared_target) = prepared_target { + return match prepared_target { + PreparedWriteTarget::Existing(PreparedWriteRoot(prepared)) => { + if root_policy != RootPolicy::ExistingAdoptionRoot || prepared.path != root { + return Err(prepared_target_mismatch(root)); + } + prepared.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.to_path_buf(), + error, + })?; + Ok(prepared) + } + PreparedWriteTarget::NewChild { + parent: PreparedWriteRoot(parent), + child, + } => { + let expected = parent.path.join(&child); + let mut components = Path::new(&child).components(); + let is_normal_child = matches!( + (components.next(), components.next()), + (Some(std::path::Component::Normal(_)), None) + ); + if root_policy != RootPolicy::NewDestination || expected != root || !is_normal_child + { + return Err(prepared_target_mismatch(root)); + } + create_new_root_from_prepared_parent(root, &child, parent, journal) + } + }; + } + + match root_policy { + RootPolicy::NewDestination => create_new_root(root, journal), + RootPolicy::ExistingAdoptionRoot => { + validate_existing_root(root)?; + PinnedRoot::open(root).map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.to_path_buf(), + error, + }) + } + } +} + +fn prepared_target_mismatch(root: &Path) -> OperationFailure { + OperationFailure { + stage: ApplyStage::ValidatePolicy, + path: root.to_path_buf(), + error: io::Error::new( + io::ErrorKind::InvalidInput, + "prepared filesystem target does not match the write plan root and kind", + ), + } +} + +#[cfg(windows)] +fn create_new_root_from_prepared_parent( + root: &Path, + child: &OsStr, + parent: PinnedRoot, + journal: &mut CreationJournal, +) -> Result { + parent.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: parent.path.clone(), + error, + })?; + let created = create_windows_directory_at(&parent.directory, child).map_err(|error| { + OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error, + } + })?; + let directory = retain_windows_created_directory( + &parent.directory, + child, + root.to_path_buf(), + created, + journal, + )?; + let identity = + windows_identity_from_directory(&directory).map_err(|error| OperationFailure { + stage: ApplyStage::RecordOwnership, + path: root.to_path_buf(), + error, + })?; + let PinnedRoot { + directory: parent_directory, + mut ancestor_guards, + .. + } = parent; + ancestor_guards.push(parent_directory); + let pinned = PinnedRoot { + directory, + identity, + path: root.to_path_buf(), + ancestor_guards, + }; + pinned.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.to_path_buf(), + error, + })?; + Ok(pinned) +} + +#[cfg(unix)] +fn create_new_root_from_prepared_parent( + root: &Path, + child: &OsStr, + parent: PinnedRoot, + journal: &mut CreationJournal, +) -> Result { + parent.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: parent.path.clone(), + error, + })?; + create_directory_at(&parent.directory, child).map_err(|error| OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error, + })?; + // Journal immediately after mkdirat. The retained child handle is opened + // in a second syscall, so rollback must preserve this directory even when + // that open or any following validation fails. + journal + .directories + .push(JournalEntry::directory_without_identity( + root.to_path_buf(), + None, + )); + let directory = + open_directory_at(&parent.directory, child).map_err(|error| OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error, + })?; + parent.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: parent.path.clone(), + error, + })?; + let metadata = directory.metadata().map_err(|error| OperationFailure { + stage: ApplyStage::RecordOwnership, + path: root.to_path_buf(), + error, + })?; + let identity = entry_identity(&metadata).expect("Unix directory identity"); + let pinned = PinnedRoot { + directory, + identity, + path: root.to_path_buf(), + }; + pinned.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.to_path_buf(), + error, + })?; + Ok(pinned) +} + +#[cfg(not(any(unix, windows)))] +fn create_new_root_from_prepared_parent( + root: &Path, + _child: &OsStr, + parent: PinnedRoot, + journal: &mut CreationJournal, +) -> Result { + parent.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: parent.path.clone(), + error, + })?; + let result = create_new_root(root, journal); + parent.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: parent.path.clone(), + error, + })?; + result +} + +#[cfg(not(windows))] +fn create_new_root( + root: &Path, + journal: &mut CreationJournal, +) -> Result { + match fs::symlink_metadata(root) { + Ok(_) => { + return Err(OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error: io::Error::new( + io::ErrorKind::AlreadyExists, + "new project destination already exists", + ), + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error, + }); + } + } + + if let Some(parent) = nonempty_parent(root) { + ensure_directory_tree(parent, journal)?; + } + + fs::create_dir(root).map_err(|error| OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error, + })?; + record_created_directory(root, journal)?; + PinnedRoot::open(root).map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.to_path_buf(), + error, + }) +} + +#[cfg(windows)] +fn create_new_root( + root: &Path, + journal: &mut CreationJournal, +) -> Result { + match fs::symlink_metadata(root) { + Ok(_) => { + return Err(OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error: io::Error::new( + io::ErrorKind::AlreadyExists, + "new project destination already exists", + ), + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error, + }); + } + } + + // Walk upward without mutating until one real ancestor can be pinned. + // Every missing component is then created relative to the retained handle; + // the newly created root handle itself becomes `PinnedRoot` and is never + // reopened through its mutable pathname. + let mut missing = Vec::<(PathBuf, OsString)>::new(); + let mut existing = root.to_path_buf(); + loop { + match fs::symlink_metadata(&existing) { + Ok(metadata) => { + require_real_directory(&existing, &metadata)?; + break; + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let name = existing.file_name().ok_or_else(|| OperationFailure { + stage: ApplyStage::CreateRoot, + path: root.to_path_buf(), + error: io::Error::new( + io::ErrorKind::InvalidInput, + "new project destination has no creatable path component", + ), + })?; + missing.push((existing.clone(), name.to_os_string())); + existing = existing + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + } + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path: existing, + error, + }); + } + } + } + + let pinned_ancestor = PinnedRoot::open(&existing).map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: existing.clone(), + error, + })?; + let PinnedRoot { + directory: mut current, + mut ancestor_guards, + .. + } = pinned_ancestor; + + let missing_count = missing.len(); + for (index, (path, name)) in missing.into_iter().rev().enumerate() { + ancestor_guards.push(current.try_clone().map_err(|error| OperationFailure { + stage: ApplyStage::RecordOwnership, + path: path.clone(), + error, + })?); + let created = match create_windows_directory_at(¤t, &name) { + Ok(created) => created, + Err(error) + if error.kind() == io::ErrorKind::AlreadyExists && index + 1 != missing_count => + { + use cap_fs_ext::DirExt as _; + + current = current + .open_dir_nofollow(&name) + .map_err(|error| OperationFailure { + stage: ApplyStage::CreateDirectory, + path, + error, + })?; + continue; + } + Err(error) => { + return Err(OperationFailure { + stage: if index + 1 == missing_count { + ApplyStage::CreateRoot + } else { + ApplyStage::CreateDirectory + }, + path, + error, + }); + } + }; + current = retain_windows_created_directory(¤t, &name, path, created, journal)?; + } + + let identity = windows_identity_from_directory(¤t).map_err(|error| OperationFailure { + stage: ApplyStage::RecordOwnership, + path: root.to_path_buf(), + error, + })?; + let pinned = PinnedRoot { + directory: current, + identity, + path: root.to_path_buf(), + ancestor_guards, + }; + pinned.validate().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.to_path_buf(), + error, + })?; + Ok(pinned) +} + +fn validate_existing_root(root: &Path) -> Result<(), OperationFailure> { + let metadata = fs::symlink_metadata(root).map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.to_path_buf(), + error, + })?; + if !metadata.file_type().is_dir() { + return Err(OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.to_path_buf(), + error: io::Error::new( + io::ErrorKind::NotADirectory, + "adoption root is not a real directory", + ), + }); + } + Ok(()) +} + +#[cfg(not(windows))] +fn nonempty_parent(path: &Path) -> Option<&Path> { + path.parent() + .filter(|parent| !parent.as_os_str().is_empty()) +} + +/// Ensure a possibly-outside-root ancestor path for a new destination. +/// Existing ancestors terminate recursion; only directories created while +/// unwinding are journaled. +#[cfg(not(windows))] +fn ensure_directory_tree( + directory: &Path, + journal: &mut CreationJournal, +) -> Result<(), OperationFailure> { + match fs::symlink_metadata(directory) { + Ok(metadata) => return require_real_directory(directory, &metadata), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path: directory.to_path_buf(), + error, + }); + } + } + + if let Some(parent) = nonempty_parent(directory) { + ensure_directory_tree(parent, journal)?; + } + create_directory_if_missing(directory, journal) +} + +#[cfg(unix)] +#[derive(Debug)] +struct PinnedRoot { + directory: fs::File, + identity: EntryIdentity, + path: PathBuf, +} + +#[cfg(not(windows))] +#[derive(Debug)] +struct DirectoryCache; + +#[cfg(not(windows))] +impl DirectoryCache { + fn new() -> Self { + Self + } +} + +#[cfg(windows)] +#[derive(Debug, Default)] +struct DirectoryCache { + directories: BTreeMap, +} + +#[cfg(windows)] +impl DirectoryCache { + fn new() -> Self { + Self::default() + } +} + +#[cfg(unix)] +impl PinnedRoot { + fn open(path: &Path) -> io::Result { + let directory = open_directory_path(path)?; + let identity = entry_identity(&directory.metadata()?).expect("Unix directory identity"); + let root = Self { + directory, + identity, + path: path.to_path_buf(), + }; + root.validate()?; + Ok(root) + } + + fn validate(&self) -> io::Result<()> { + let metadata = fs::symlink_metadata(&self.path)?; + if !metadata.file_type().is_dir() + || entry_identity(&metadata) != Some(self.identity) + || entry_identity(&self.directory.metadata()?) != Some(self.identity) + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "project root identity changed while applying the write plan", + )); + } + Ok(()) + } + + fn cursor(&self) -> io::Result { + self.validate()?; + Ok(DirectoryCursor { + directory: self.directory.try_clone()?, + path: self.path.clone(), + }) + } +} + +#[cfg(unix)] +#[derive(Debug)] +struct DirectoryCursor { + directory: fs::File, + path: PathBuf, +} + +#[cfg(unix)] +impl DirectoryCursor { + fn create_new_file(&self, name: &OsStr) -> io::Result<(fs::File, Option)> { + // Duplicate the parent capability before the mutating openat. If the + // process is out of descriptors, fail before creating a file that the + // caller cannot journal through its retained parent anchor. + let parent = self.directory.try_clone()?; + let descriptor = rustix::fs::openat( + &self.directory, + name, + rustix::fs::OFlags::WRONLY + | rustix::fs::OFlags::CREATE + | rustix::fs::OFlags::EXCL + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::from_raw_mode(0o666), + )?; + let file = fs::File::from(descriptor); + let anchor = PathAnchor { + parent, + name: name.to_os_string(), + }; + Ok((file, Some(anchor))) + } +} + +#[cfg(windows)] +#[derive(Debug)] +struct PinnedRoot { + directory: cap_std::fs::Dir, + identity: EntryIdentity, + path: PathBuf, + // `spock new` may create a missing chain below the nearest existing + // ancestor. Keep every ancestor locked until the plan commits so moving a + // parent cannot carry the pinned project outside the requested pathname. + ancestor_guards: Vec, +} + +#[cfg(windows)] +impl PinnedRoot { + fn open(path: &Path) -> io::Result { + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "project root is not a real directory", + )); + } + let directory = cap_std::fs::Dir::from_std_file(open_directory_path(path)?); + let identity = windows_identity_from_directory(&directory)?; + let root = Self { + directory, + identity, + path: path.to_path_buf(), + ancestor_guards: Vec::new(), + }; + root.validate()?; + Ok(root) + } + + fn validate(&self) -> io::Result<()> { + let metadata = fs::symlink_metadata(&self.path)?; + if !metadata.file_type().is_dir() + || !windows_path_matches_identity(&self.path, &self.identity)? + || windows_identity_from_directory(&self.directory)? != self.identity + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "project root identity changed while applying the write plan", + )); + } + Ok(()) + } + + fn cursor(&self) -> io::Result { + self.validate()?; + let root = self.directory.try_clone()?; + Ok(DirectoryCursor { + directory: root.try_clone()?, + root_identity: windows_identity_from_directory(&root)?, + root, + root_path: self.path.clone(), + }) + } +} + +#[cfg(windows)] +#[derive(Debug)] +struct DirectoryCursor { + directory: cap_std::fs::Dir, + root: cap_std::fs::Dir, + root_identity: EntryIdentity, + root_path: PathBuf, +} + +#[cfg(windows)] +impl DirectoryCursor { + fn validate_root(&self) -> io::Result<()> { + let metadata = fs::symlink_metadata(&self.root_path)?; + if !metadata.file_type().is_dir() + || !windows_path_matches_identity(&self.root_path, &self.root_identity)? + || windows_identity_from_directory(&self.root)? != self.root_identity + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "project root identity changed while applying the write plan", + )); + } + Ok(()) + } + + fn create_new_file(&self, name: &OsStr) -> io::Result<(fs::File, Option)> { + use cap_fs_ext::{FollowSymlinks, OpenOptionsFollowExt}; + use cap_std::fs::OpenOptionsExt as _; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + self.validate_root()?; + let mut options = cap_std::fs::OpenOptions::new(); + options + .write(true) + .create_new(true) + .follow(FollowSymlinks::No) + // `create_new` already guarantees the final component did not + // exist. Supplying the no-follow flag explicitly prevents + // cap-std from performing a fallible metadata probe after the + // successful create syscall, so the returned handle can always be + // journaled by the caller before any later fallible operation. + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE); + let file = self.directory.open_with(name, &options)?.into_std(); + Ok((file, None)) + } +} + +#[cfg(not(any(unix, windows)))] +#[derive(Debug)] +struct PinnedRoot { + directory: fs::File, + identity: Option, + path: PathBuf, +} + +#[cfg(not(any(unix, windows)))] +impl PinnedRoot { + fn open(path: &Path) -> io::Result { + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "project root is not a real directory", + )); + } + let directory = open_directory_path(path)?; + let identity = entry_identity(&directory.metadata()?); + let root = Self { + directory, + identity, + path: path.to_path_buf(), + }; + root.validate()?; + Ok(root) + } + + fn validate(&self) -> io::Result<()> { + let metadata = fs::symlink_metadata(&self.path)?; + if !metadata.file_type().is_dir() + || self.identity.is_none() + || entry_identity(&metadata) != self.identity + || entry_identity(&self.directory.metadata()?) != self.identity + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "project root identity changed while applying the write plan", + )); + } + Ok(()) + } + + fn cursor(&self) -> io::Result { + self.validate()?; + Ok(DirectoryCursor { + root: self.directory.try_clone()?, + root_identity: self.identity, + root_path: self.path.clone(), + path: self.path.clone(), + }) + } +} + +#[cfg(not(any(unix, windows)))] +#[derive(Debug)] +struct DirectoryCursor { + root: fs::File, + root_identity: Option, + root_path: PathBuf, + path: PathBuf, +} + +#[cfg(not(any(unix, windows)))] +impl DirectoryCursor { + fn validate_root(&self) -> io::Result<()> { + let metadata = fs::symlink_metadata(&self.root_path)?; + if !metadata.file_type().is_dir() + || self.root_identity.is_none() + || entry_identity(&metadata) != self.root_identity + || entry_identity(&self.root.metadata()?) != self.root_identity + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "project root identity changed while applying the write plan", + )); + } + Ok(()) + } + + fn create_new_file(&self, name: &OsStr) -> io::Result<(fs::File, Option)> { + self.validate_root()?; + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(self.path.join(name))?; + self.validate_root()?; + Ok((file, None)) + } +} + +#[cfg(unix)] +fn ensure_relative_directories( + root: &PinnedRoot, + relative: &Path, + journal: &mut CreationJournal, + _cache: &mut DirectoryCache, +) -> Result { + let mut current = root.cursor().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.path.clone(), + error, + })?; + for component in relative.components() { + let std::path::Component::Normal(segment) = component else { + return Err(invalid_planned_parent(&root.path, relative)); + }; + let path = current.path.join(segment); + match open_directory_at(¤t.directory, segment) { + Ok(directory) => current = DirectoryCursor { directory, path }, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let created = match create_directory_at(¤t.directory, segment) { + Ok(()) => true, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false, + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path, + error, + }); + } + }; + if created { + // `mkdirat` has already mutated the filesystem and does + // not return a handle. Record the logical creation before + // any fallible open or metadata call. Directory rollback + // is deliberately non-mutating because ownership cannot be + // proven across this gap. + journal + .directories + .push(JournalEntry::directory_without_identity(path.clone(), None)); + } + let directory = + open_directory_at(¤t.directory, segment).map_err(|error| { + OperationFailure { + stage: ApplyStage::CreateDirectory, + path: path.clone(), + error, + } + })?; + current = DirectoryCursor { directory, path }; + } + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path, + error, + }); + } + } + } + Ok(current) +} + +#[cfg(windows)] +fn ensure_relative_directories( + root: &PinnedRoot, + relative: &Path, + journal: &mut CreationJournal, + cache: &mut DirectoryCache, +) -> Result { + use cap_fs_ext::DirExt as _; + + let mut current = root.cursor().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.path.clone(), + error, + })?; + let mut relative_cursor = PathBuf::new(); + for component in relative.components() { + let std::path::Component::Normal(segment) = component else { + return Err(invalid_planned_parent(&root.path, relative)); + }; + relative_cursor.push(segment); + let path = root.path.join(&relative_cursor); + + current.validate_root().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.path.clone(), + error, + })?; + + let directory = if let Some(cached) = cache.directories.get(&relative_cursor) { + cached.try_clone().map_err(|error| OperationFailure { + stage: ApplyStage::RecordOwnership, + path: path.clone(), + error, + })? + } else { + let directory = match current.directory.open_dir_nofollow(segment) { + Ok(directory) => directory, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + match create_windows_directory_at(¤t.directory, segment) { + Ok(created) => retain_windows_created_directory( + ¤t.directory, + segment, + path.clone(), + created, + journal, + )?, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => current + .directory + .open_dir_nofollow(segment) + .map_err(|error| OperationFailure { + stage: ApplyStage::CreateDirectory, + path: path.clone(), + error, + })?, + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path, + error, + }); + } + } + } + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path, + error, + }); + } + }; + cache.directories.insert( + relative_cursor.clone(), + directory.try_clone().map_err(|error| OperationFailure { + stage: ApplyStage::RecordOwnership, + path: path.clone(), + error, + })?, + ); + directory + }; + current = DirectoryCursor { + directory, + root: current.root.try_clone().map_err(|error| OperationFailure { + stage: ApplyStage::RecordOwnership, + path: path.clone(), + error, + })?, + root_identity: windows_identity_from_directory(¤t.root).map_err(|error| { + OperationFailure { + stage: ApplyStage::RecordOwnership, + path: path.clone(), + error, + } + })?, + root_path: current.root_path.clone(), + }; + } + Ok(current) +} + +#[cfg(not(any(unix, windows)))] +fn ensure_relative_directories( + root: &PinnedRoot, + relative: &Path, + journal: &mut CreationJournal, + _cache: &mut DirectoryCache, +) -> Result { + let mut current = root.cursor().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.path.clone(), + error, + })?; + for component in relative.components() { + let std::path::Component::Normal(segment) = component else { + return Err(invalid_planned_parent(&root.path, relative)); + }; + current.validate_root().map_err(|error| OperationFailure { + stage: ApplyStage::ValidateRoot, + path: root.path.clone(), + error, + })?; + current.path.push(segment); + match fs::symlink_metadata(¤t.path) { + Ok(metadata) => require_real_directory(¤t.path, &metadata)?, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + create_directory_if_missing(¤t.path, journal)?; + } + Err(error) => { + return Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path: current.path, + error, + }); + } + } + } + Ok(current) +} + +fn invalid_planned_parent(root: &Path, relative: &Path) -> OperationFailure { + OperationFailure { + stage: ApplyStage::CreateDirectory, + path: root.join(relative), + error: io::Error::new( + io::ErrorKind::InvalidInput, + "planned parent is not a normalized relative path", + ), + } +} + +#[cfg(not(windows))] +fn create_directory_if_missing( + directory: &Path, + journal: &mut CreationJournal, +) -> Result<(), OperationFailure> { + match fs::create_dir(directory) { + Ok(()) => record_created_directory(directory, journal), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + // Another creator won the race. Accept only a real directory and + // do not journal it: this invocation does not own that path. + let metadata = fs::symlink_metadata(directory).map_err(|error| OperationFailure { + stage: ApplyStage::CreateDirectory, + path: directory.to_path_buf(), + error, + })?; + require_real_directory(directory, &metadata) + } + Err(error) => Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path: directory.to_path_buf(), + error, + }), + } +} + +fn require_real_directory( + directory: &Path, + metadata: &fs::Metadata, +) -> Result<(), OperationFailure> { + if metadata.file_type().is_dir() { + Ok(()) + } else { + Err(OperationFailure { + stage: ApplyStage::CreateDirectory, + path: directory.to_path_buf(), + error: io::Error::new( + io::ErrorKind::NotADirectory, + "path exists but is not a real directory", + ), + }) + } +} + +#[cfg(unix)] +fn open_directory_path(path: &Path) -> io::Result { + let descriptor = rustix::fs::openat( + rustix::fs::CWD, + path, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + )?; + Ok(fs::File::from(descriptor)) +} + +#[cfg(windows)] +fn open_directory_path(path: &Path) -> io::Result { + open_windows_entry_path(path) +} + +#[cfg(windows)] +fn open_windows_entry_path(path: &Path) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; + + const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + OpenOptions::new() + .read(true) + // A retained directory handle is a filesystem capability on Windows. + // Denying delete sharing prevents the directory from being renamed or + // removed while descendant operations are resolved through it. + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) +} + +#[cfg(windows)] +fn create_windows_directory_at(parent: &cap_std::fs::Dir, name: &OsStr) -> io::Result { + use fs_at::os::windows::OpenOptionsExt as _; + use windows_sys::Wdk::Storage::FileSystem::{FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_TRAVERSE, FILE_WRITE_ATTRIBUTES, + }; + + let parent = parent.try_clone()?.into_std_file(); + let mut options = fs_at::OpenOptions::default(); + options + .create_new(true) + .desired_access( + FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES | FILE_TRAVERSE | FILE_WRITE_ATTRIBUTES, + ) + // `mkdir_at` performs a second reparse probe after creating on + // Windows. Requesting FILE_CREATE through `open_at` gives us the + // created directory handle in one operation, so every mutation can be + // journaled even if later validation fails. + .create_options(FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT); + options.open_at(&parent, name) +} + +#[cfg(windows)] +fn retain_windows_created_directory( + parent: &cap_std::fs::Dir, + name: &OsStr, + path: PathBuf, + created: fs::File, + journal: &mut CreationJournal, +) -> Result { + use cap_fs_ext::DirExt as _; + + let identity = match windows_identity_from_file(&created) { + Ok(identity) => identity, + Err(error) => { + journal + .directories + .push(JournalEntry::directory_without_identity_with_live_file( + path.clone(), + None, + created, + )); + return Err(OperationFailure { + stage: ApplyStage::RecordOwnership, + path, + error, + }); + } + }; + let locked = match parent.open_dir_nofollow(name) { + Ok(locked) => locked, + Err(error) => { + journal + .directories + .push(JournalEntry::directory_with_live_file( + path.clone(), + Some(identity), + None, + created, + )); + return Err(OperationFailure { + stage: ApplyStage::RecordOwnership, + path, + error, + }); + } + }; + let locked_identity = match windows_identity_from_directory(&locked) { + Ok(identity) => identity, + Err(error) => { + journal + .directories + .push(JournalEntry::directory_with_live_file( + path.clone(), + Some(identity), + None, + created, + )); + return Err(OperationFailure { + stage: ApplyStage::RecordOwnership, + path, + error, + }); + } + }; + if locked_identity != identity { + journal + .directories + .push(JournalEntry::directory_with_live_file( + path.clone(), + Some(identity), + None, + created, + )); + return Err(OperationFailure { + stage: ApplyStage::RecordOwnership, + path, + error: io::Error::new( + io::ErrorKind::AlreadyExists, + "created directory identity changed before it could be pinned", + ), + }); + } + + let live = match locked.try_clone().map(cap_std::fs::Dir::into_std_file) { + Ok(live) => live, + Err(error) => { + journal + .directories + .push(JournalEntry::directory_with_live_file( + path.clone(), + Some(identity), + None, + created, + )); + return Err(OperationFailure { + stage: ApplyStage::RecordOwnership, + path, + error, + }); + } + }; + journal + .directories + .push(JournalEntry::directory_with_live_file( + path, + Some(identity), + None, + live, + )); + Ok(locked) +} + +#[cfg(not(any(unix, windows)))] +fn open_directory_path(path: &Path) -> io::Result { + fs::File::open(path) +} + +#[cfg(unix)] +fn open_directory_at(parent: &fs::File, name: &OsStr) -> io::Result { + let descriptor = rustix::fs::openat( + parent, + name, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + )?; + Ok(fs::File::from(descriptor)) +} + +#[cfg(unix)] +fn create_directory_at(parent: &fs::File, name: &OsStr) -> io::Result<()> { + Ok(rustix::fs::mkdirat( + parent, + name, + rustix::fs::Mode::from_raw_mode(0o777), + )?) +} + +#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos"))] +fn rename_noreplace_at( + from_parent: &fs::File, + from: &OsStr, + to_parent: &fs::File, + to: &OsStr, +) -> io::Result<()> { + Ok(rustix::fs::renameat_with( + from_parent, + from, + to_parent, + to, + rustix::fs::RenameFlags::NOREPLACE, + )?) +} + +#[cfg(all( + unix, + not(any(target_os = "linux", target_os = "android", target_os = "macos")) +))] +fn rename_noreplace_at( + _from_parent: &fs::File, + _from: &OsStr, + _to_parent: &fs::File, + _to: &OsStr, +) -> io::Result<()> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "atomic no-replace restore is unavailable on this Unix platform", + )) +} + +#[cfg(unix)] +fn identity_at(parent: &fs::File, name: &OsStr) -> io::Result { + let metadata = rustix::fs::statat(parent, name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW)?; + Ok(EntryIdentity { + volume: stat_identity_part(metadata.st_dev)?, + file: stat_identity_part(metadata.st_ino)?, + }) +} + +#[cfg(unix)] +fn stat_identity_part(value: T) -> io::Result +where + T: TryInto, + T::Error: fmt::Display, +{ + value.try_into().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("filesystem identity does not fit u64: {error}"), + ) + }) +} + +#[cfg(unix)] +fn unlink_at(parent: &fs::File, name: &OsStr, kind: CreatedPathKind) -> io::Result<()> { + let flags = match kind { + CreatedPathKind::File => rustix::fs::AtFlags::empty(), + CreatedPathKind::Directory => rustix::fs::AtFlags::REMOVEDIR, + }; + Ok(rustix::fs::unlinkat(parent, name, flags)?) +} + +#[derive(Debug)] +struct PathAnchor { + #[cfg(unix)] + parent: fs::File, + #[cfg(unix)] + name: OsString, +} + +#[cfg(unix)] +fn path_anchor(path: &Path) -> io::Result { + let parent = path + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "created path has no parent"))?; + let name = path.file_name().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "created path has no file name") + })?; + Ok(PathAnchor { + parent: open_directory_path(parent)?, + name: name.to_os_string(), + }) +} + +#[cfg(not(any(unix, windows)))] +fn path_anchor(_path: &Path) -> io::Result { + Ok(PathAnchor {}) +} + +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct EntryIdentity { + volume: u64, + file: u64, +} + +#[cfg(windows)] +type EntryIdentity = same_file::Handle; + +#[cfg(not(any(unix, windows)))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct EntryIdentity; + +#[cfg(unix)] +fn entry_identity(metadata: &fs::Metadata) -> Option { + use std::os::unix::fs::MetadataExt; + + Some(EntryIdentity { + volume: metadata.dev(), + file: metadata.ino(), + }) +} + +#[cfg(not(any(unix, windows)))] +fn entry_identity(_metadata: &fs::Metadata) -> Option { + None +} + +#[cfg(unix)] +fn journal_identity_from_file(file: &fs::File) -> io::Result> { + file.metadata().map(|metadata| entry_identity(&metadata)) +} + +#[cfg(windows)] +fn windows_identity_from_file(file: &fs::File) -> io::Result { + same_file::Handle::from_file(file.try_clone()?) +} + +#[cfg(windows)] +fn windows_identity_from_directory(directory: &cap_std::fs::Dir) -> io::Result { + let file = directory.try_clone()?.into_std_file(); + windows_identity_from_file(&file) +} + +#[cfg(windows)] +fn windows_file_matches_identity(file: &fs::File, expected: &EntryIdentity) -> io::Result { + windows_identity_from_file(file).map(|current| ¤t == expected) +} + +#[cfg(windows)] +fn windows_path_matches_identity(path: &Path, expected: &EntryIdentity) -> io::Result { + let current = open_windows_entry_path(path)?; + windows_file_matches_identity(¤t, expected) +} + +#[cfg(windows)] +fn journal_identity_from_file(file: &fs::File) -> io::Result> { + windows_identity_from_file(file).map(Some) +} + +#[cfg(not(any(unix, windows)))] +fn journal_identity_from_file(file: &fs::File) -> io::Result> { + file.metadata().map(|_| None) +} + +#[cfg(unix)] +fn journal_identity_from_directory( + _path: &Path, + metadata: &fs::Metadata, +) -> io::Result> { + Ok(entry_identity(metadata)) +} + +#[cfg(not(any(unix, windows)))] +fn journal_identity_from_directory( + _path: &Path, + metadata: &fs::Metadata, +) -> io::Result> { + Ok(entry_identity(metadata)) +} + +#[cfg(all(windows, test))] +fn identity_from_path(path: &Path) -> io::Result> { + let entry = open_windows_entry_path(path)?; + windows_identity_from_file(&entry).map(Some) +} + +#[cfg(not(any(unix, windows)))] +fn identity_from_path(path: &Path) -> io::Result> { + fs::symlink_metadata(path).map(|metadata| entry_identity(&metadata)) +} + +#[derive(Debug)] +struct JournalEntry { + path: PathBuf, + #[cfg_attr(windows, allow(dead_code))] + identity: Option, + _live_file: Option, + #[cfg_attr(windows, allow(dead_code))] + anchor: Option, +} + +impl JournalEntry { + #[cfg(not(windows))] + fn directory( + path: PathBuf, + identity: Option, + anchor: Option, + ) -> Self { + Self { + path, + identity, + _live_file: None, + anchor, + } + } + + fn file( + path: PathBuf, + file: fs::File, + identity: Option, + anchor: Option, + ) -> Self { + Self { + path, + identity, + _live_file: Some(file), + anchor, + } + } + + #[cfg(not(windows))] + fn directory_without_identity(path: PathBuf, anchor: Option) -> Self { + Self { + path, + identity: None, + _live_file: None, + anchor, + } + } + + #[cfg(windows)] + fn directory_with_live_file( + path: PathBuf, + identity: Option, + anchor: Option, + live_file: fs::File, + ) -> Self { + Self { + path, + identity, + _live_file: Some(live_file), + anchor, + } + } + + #[cfg(windows)] + fn directory_without_identity_with_live_file( + path: PathBuf, + anchor: Option, + live_file: fs::File, + ) -> Self { + Self::directory_with_live_file(path, None, anchor, live_file) + } +} + +#[cfg(not(windows))] +fn record_created_directory( + directory: &Path, + journal: &mut CreationJournal, +) -> Result<(), OperationFailure> { + let anchor = path_anchor(directory).map_err(|error| { + journal + .directories + .push(JournalEntry::directory_without_identity( + directory.to_path_buf(), + None, + )); + OperationFailure { + stage: ApplyStage::RecordOwnership, + path: directory.to_path_buf(), + error, + } + })?; + match fs::symlink_metadata(directory) { + Ok(metadata) => match journal_identity_from_directory(directory, &metadata) { + Ok(identity) => { + journal.directories.push(JournalEntry::directory( + directory.to_path_buf(), + identity, + Some(anchor), + )); + Ok(()) + } + Err(error) => { + journal + .directories + .push(JournalEntry::directory_without_identity( + directory.to_path_buf(), + Some(anchor), + )); + Err(OperationFailure { + stage: ApplyStage::RecordOwnership, + path: directory.to_path_buf(), + error, + }) + } + }, + Err(error) => { + journal + .directories + .push(JournalEntry::directory_without_identity( + directory.to_path_buf(), + Some(anchor), + )); + Err(OperationFailure { + stage: ApplyStage::RecordOwnership, + path: directory.to_path_buf(), + error, + }) + } + } +} + +#[derive(Debug, Default)] +struct CreationJournal { + files: Vec, + directories: Vec, +} + +fn failed( + stage: ApplyStage, + path: impl Into, + source: io::Error, + journal: CreationJournal, +) -> ApplyError { + ApplyError { + stage, + path: path.into(), + source, + rollback: journal.rollback(), + } +} + +impl CreationJournal { + fn rollback(self) -> RollbackReport { + let mut residuals = Vec::new(); + + for entry in self.files.into_iter().rev() { + rollback_entry(entry, CreatedPathKind::File, &mut residuals); + } + for entry in self.directories.into_iter().rev() { + rollback_entry(entry, CreatedPathKind::Directory, &mut residuals); + } + + RollbackReport { residuals } + } +} + +fn rollback_entry( + entry: JournalEntry, + kind: CreatedPathKind, + residuals: &mut Vec, +) { + #[cfg(windows)] + { + // Windows has no safe, stable, no-replace restore primitive in the + // APIs used by this crate. In particular, `std::fs::rename` may replace + // its destination. Preserve every known creation and report it rather + // than risk removing or overwriting a concurrent replacement. The + // recorded path is the logical creation path; another process may have + // moved the entry after its retained handle is released. + residuals.push(RollbackResidual { + path: entry.path, + kind, + error: io::Error::other( + "Windows rollback is intentionally non-mutating; the created entry was preserved and its recorded path may no longer be current", + ), + }); + } + + #[cfg(not(windows))] + if kind == CreatedPathKind::Directory { + // `mkdir`/`mkdirat` does not return a live handle. A concurrent actor + // can move the new directory and install another directory before we + // open and record its identity. Never remove a directory based on that + // post-create observation: the replacement may belong to somebody + // else. The logical path is still useful for cleanup guidance, though + // it may no longer name the invocation-created directory. + residuals.push(RollbackResidual { + path: entry.path, + kind, + error: io::Error::other( + "directory rollback is intentionally non-mutating because directory creation cannot be atomically bound to an ownership handle; the recorded path may no longer be current", + ), + }); + return; + } + + #[cfg(not(windows))] + if entry.identity.is_none() { + residuals.push(RollbackResidual { + path: entry.path, + kind, + error: io::Error::new( + io::ErrorKind::AlreadyExists, + "created path has no stable identity; preserved it during rollback", + ), + }); + return; + } + + #[cfg(unix)] + if entry.anchor.is_some() { + rollback_anchored_unix(entry, kind, residuals); + } else { + residuals.push(RollbackResidual { + path: entry.path, + kind, + error: io::Error::other( + "created path lost its retained parent anchor; preserved it during rollback", + ), + }); + } + + #[cfg(not(any(unix, windows)))] + rollback_quarantined_path(entry, kind, residuals); +} + +#[cfg(not(windows))] +fn quarantine_name() -> OsString { + OsString::from(format!( + ".spock-rollback-{}-{}", + std::process::id(), + NEXT_QUARANTINE.fetch_add(1, Ordering::Relaxed) + )) +} + +#[cfg(unix)] +fn move_to_quarantine_at(parent: &fs::File, source: &OsStr) -> io::Result { + loop { + let quarantine = quarantine_name(); + match rename_noreplace_at(parent, source, parent, &quarantine) { + Ok(()) => return Ok(quarantine), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } +} + +#[cfg(unix)] +fn rollback_anchored_unix( + entry: JournalEntry, + kind: CreatedPathKind, + residuals: &mut Vec, +) { + let anchor = entry.anchor.as_ref().expect("checked anchored entry"); + let quarantine_name = match move_to_quarantine_at(&anchor.parent, &anchor.name) { + Ok(quarantine) => quarantine, + Err(error) if error.kind() == io::ErrorKind::NotFound => return, + Err(error) => { + residuals.push(RollbackResidual { + path: entry.path, + kind, + error, + }); + return; + } + }; + let quarantine_path = entry + .path + .parent() + .unwrap_or_else(|| Path::new(".")) + .join(&quarantine_name); + + let current_identity = identity_at(&anchor.parent, &quarantine_name); + if current_identity + .as_ref() + .is_ok_and(|identity| entry.identity.as_ref() == Some(identity)) + { + let removal = unlink_at(&anchor.parent, &quarantine_name, kind); + if let Err(error) = removal { + match rename_noreplace_at( + &anchor.parent, + &quarantine_name, + &anchor.parent, + &anchor.name, + ) { + Ok(()) => { + residuals.push(RollbackResidual { + path: entry.path, + kind, + error, + }); + } + Err(restore_error) => residuals.push(RollbackResidual { + path: quarantine_path, + kind, + error: io::Error::new( + restore_error.kind(), + format!( + "could not remove invocation-owned path or restore it from rollback quarantine ({error}; {restore_error})" + ), + ), + }), + } + return; + } + return; + } + + let identity_error = current_identity.err(); + match rename_noreplace_at( + &anchor.parent, + &quarantine_name, + &anchor.parent, + &anchor.name, + ) { + Ok(()) => { + residuals.push(RollbackResidual { + path: entry.path, + kind, + error: identity_error.unwrap_or_else(|| { + io::Error::new( + io::ErrorKind::AlreadyExists, + "path identity changed after creation; restored concurrent replacement", + ) + }), + }); + } + Err(restore_error) => { + residuals.push(RollbackResidual { + path: quarantine_path, + kind, + error: io::Error::new( + restore_error.kind(), + format!( + "path identity changed; preserved replacement in rollback quarantine ({restore_error})" + ), + ), + }); + } + } +} + +#[cfg(not(any(unix, windows)))] +fn rollback_quarantined_path( + entry: JournalEntry, + kind: CreatedPathKind, + residuals: &mut Vec, +) { + let parent = entry.path.parent().unwrap_or_else(|| Path::new(".")); + let (quarantine_path, quarantined_entry) = loop { + let quarantine_path = parent.join(quarantine_name()); + match fs::create_dir(&quarantine_path) { + Ok(()) => break (quarantine_path.clone(), quarantine_path.join("entry")), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => { + residuals.push(RollbackResidual { + path: entry.path, + kind, + error, + }); + return; + } + } + }; + + if let Err(error) = fs::rename(&entry.path, &quarantined_entry) { + let _ = fs::remove_dir(&quarantine_path); + if error.kind() != io::ErrorKind::NotFound { + residuals.push(RollbackResidual { + path: entry.path, + kind, + error, + }); + } + return; + } + + let current_identity = identity_from_path(&quarantined_entry).ok().flatten(); + if current_identity.as_ref() == entry.identity.as_ref() { + let removal = match kind { + CreatedPathKind::File => fs::remove_file(&quarantined_entry), + CreatedPathKind::Directory => fs::remove_dir(&quarantined_entry), + }; + if let Err(error) = removal { + residuals.push(RollbackResidual { + path: quarantined_entry, + kind, + error, + }); + return; + } + let _ = fs::remove_dir(quarantine_path); + return; + } + + residuals.push(RollbackResidual { + path: quarantined_entry, + kind, + error: io::Error::new( + io::ErrorKind::AlreadyExists, + "path identity changed; preserved replacement in rollback quarantine", + ), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + use spock_project::{adoption_plan, scaffold_plan, ProjectInventory}; + + static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0); + + struct TestDirectory(PathBuf); + + impl TestDirectory { + fn new() -> Self { + loop { + let id = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir() + .join(format!("spock-write-plan-{}-{id}", std::process::id())); + match fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("could not create test directory: {error}"), + } + } + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TestDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn applies_scaffold_with_manifest_last_and_reports_exact_effects() { + let temporary = TestDirectory::new(); + let destination = temporary.path().join("demo"); + let plan = scaffold_plan(&destination, "demo", None).unwrap(); + + let summary = apply_write_plan(&plan, RootPolicy::NewDestination).unwrap(); + + assert_eq!(summary.root(), destination); + assert_eq!( + summary.created_files(), + [ + destination.join("backend/app.spock"), + destination.join(MANIFEST_FILE), + ] + ); + assert_eq!( + summary.created_directories(), + [destination.clone(), destination.join("backend")] + ); + assert!(destination.join("backend/app.spock").is_file()); + assert!(destination.join(MANIFEST_FILE).is_file()); + } + + #[test] + fn new_destination_may_create_and_reports_missing_parent_directories() { + let temporary = TestDirectory::new(); + let parent = temporary.path().join("nested"); + let destination = parent.join("demo"); + let plan = scaffold_plan(&destination, "demo", None).unwrap(); + + let summary = apply_write_plan(&plan, RootPolicy::NewDestination).unwrap(); + + assert_eq!( + summary.created_directories(), + [parent, destination.clone(), destination.join("backend"),] + ); + } + + #[test] + fn existing_new_destination_is_never_modified() { + let temporary = TestDirectory::new(); + let destination = temporary.path().join("demo"); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("owned.txt"), "keep").unwrap(); + let plan = scaffold_plan(&destination, "demo", None).unwrap(); + + let error = apply_write_plan(&plan, RootPolicy::NewDestination).unwrap_err(); + + assert_eq!(error.stage(), ApplyStage::CreateRoot); + assert_eq!(error.io_error().kind(), io::ErrorKind::AlreadyExists); + assert!(error.rollback().is_complete()); + assert_eq!( + fs::read_to_string(destination.join("owned.txt")).unwrap(), + "keep" + ); + assert!(!destination.join(MANIFEST_FILE).exists()); + } + + #[test] + fn adoption_conflict_rolls_back_created_files_but_preserves_racer_file() { + let temporary = TestDirectory::new(); + let inventory = ProjectInventory::scan(temporary.path()).unwrap(); + let plan = adoption_plan(&inventory, Some("demo")).unwrap(); + let racer_manifest = plan.root.join(MANIFEST_FILE); + fs::write(&racer_manifest, "racer-owned\n").unwrap(); + + let error = apply_write_plan(&plan, RootPolicy::ExistingAdoptionRoot).unwrap_err(); + + assert_eq!(error.stage(), ApplyStage::CreateFile); + assert_eq!(error.path(), racer_manifest); + assert_eq!(error.io_error().kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read_to_string(racer_manifest).unwrap(), "racer-owned\n"); + #[cfg(not(windows))] + { + assert_eq!(error.rollback().residuals().len(), 1); + let residual = &error.rollback().residuals()[0]; + assert_eq!(residual.kind(), CreatedPathKind::Directory); + assert_eq!(residual.path(), plan.root.join("backend")); + assert!(!plan.root.join("backend/app.spock").exists()); + assert!(plan.root.join("backend").is_dir()); + } + #[cfg(windows)] + { + let residuals = error + .rollback() + .residuals() + .iter() + .map(|residual| (residual.path().to_path_buf(), residual.kind())) + .collect::>(); + assert_eq!( + residuals, + std::collections::BTreeSet::from([ + (plan.root.join("backend/app.spock"), CreatedPathKind::File,), + (plan.root.join("backend"), CreatedPathKind::Directory), + ]) + ); + assert!(plan.root.join("backend/app.spock").is_file()); + assert!(plan.root.join("backend").is_dir()); + assert!(fs::read_dir(&plan.root).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".spock-rollback-") + })); + } + } + + #[cfg(not(windows))] + #[test] + fn rollback_preserves_a_file_replaced_after_this_invocation_created_it() { + let temporary = TestDirectory::new(); + let inventory = ProjectInventory::scan(temporary.path()).unwrap(); + let plan = adoption_plan(&inventory, Some("demo")).unwrap(); + fs::write(plan.root.join(MANIFEST_FILE), "racer-owned\n").unwrap(); + let backend = plan.root.join("backend/app.spock"); + let moved_invocation_file = temporary.path().join("invocation-file-moved-away"); + + let error = apply_write_plan_inner(&plan, RootPolicy::ExistingAdoptionRoot, |written| { + if written == backend { + fs::rename(written, &moved_invocation_file).unwrap(); + fs::write(written, "replacement-owned-by-another-writer\n").unwrap(); + } + }) + .unwrap_err(); + + assert_eq!( + fs::read_to_string(&backend).unwrap(), + "replacement-owned-by-another-writer\n" + ); + assert!(moved_invocation_file.is_file()); + assert!(error.rollback().residuals().iter().any(|residual| { + residual.kind() == CreatedPathKind::File && residual.path() == backend + })); + } + + #[cfg(windows)] + #[test] + fn windows_live_identity_distinguishes_a_replacement_from_the_retained_file() { + let temporary = TestDirectory::new(); + let original = temporary.path().join("created.txt"); + let moved = temporary.path().join("created-moved-away.txt"); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&original) + .unwrap(); + let original_identity = journal_identity_from_file(&file).unwrap().unwrap(); + + fs::rename(&original, &moved).unwrap(); + fs::write(&original, "replacement-owned-by-another-writer\n").unwrap(); + + let replacement_identity = identity_from_path(&original).unwrap().unwrap(); + let moved_identity = identity_from_path(&moved).unwrap().unwrap(); + assert_ne!(original_identity, replacement_identity); + assert_eq!(original_identity, moved_identity); + } + + #[test] + fn rollback_reports_a_directory_that_gained_concurrent_content() { + let temporary = TestDirectory::new(); + let inventory = ProjectInventory::scan(temporary.path()).unwrap(); + let plan = adoption_plan(&inventory, Some("demo")).unwrap(); + fs::write(plan.root.join(MANIFEST_FILE), "racer-owned\n").unwrap(); + let concurrent_file = plan.root.join("backend/concurrent.txt"); + + let error = apply_write_plan_inner(&plan, RootPolicy::ExistingAdoptionRoot, |written| { + if written.ends_with("backend/app.spock") { + fs::write(&concurrent_file, "keep").unwrap(); + } + }) + .unwrap_err(); + + assert_eq!(fs::read_to_string(concurrent_file).unwrap(), "keep"); + #[cfg(not(windows))] + { + assert_eq!(error.rollback().residuals().len(), 1); + let residual = &error.rollback().residuals()[0]; + assert_eq!(residual.kind(), CreatedPathKind::Directory); + assert_eq!(residual.path(), plan.root.join("backend")); + assert!(!plan.root.join("backend/app.spock").exists()); + } + #[cfg(windows)] + { + assert_eq!(error.rollback().residuals().len(), 2); + assert!(plan.root.join("backend/app.spock").is_file()); + } + } + + #[cfg(unix)] + #[test] + fn writes_remain_confined_to_the_pinned_root_after_path_replacement() { + use std::os::unix::fs::symlink; + + let temporary = TestDirectory::new(); + let temporary_root = fs::canonicalize(temporary.path()).unwrap(); + let project = temporary_root.join("project"); + let moved_project = temporary_root.join("moved-project"); + let replacement_target = temporary_root.join("replacement-target"); + fs::create_dir(&project).unwrap(); + fs::create_dir(&replacement_target).unwrap(); + let inventory = ProjectInventory::scan(&project).unwrap(); + let plan = adoption_plan(&inventory, Some("demo")).unwrap(); + let error = apply_write_plan_inner(&plan, RootPolicy::ExistingAdoptionRoot, |written| { + if written.ends_with(MANIFEST_FILE) { + fs::rename(&project, &moved_project).unwrap(); + symlink(&replacement_target, &project).unwrap(); + } + }) + .unwrap_err(); + + assert_eq!(error.stage(), ApplyStage::ValidateRoot); + assert_eq!(error.rollback().residuals().len(), 1); + assert_eq!( + error.rollback().residuals()[0].path(), + project.join("backend") + ); + assert!(!moved_project.join("backend/app.spock").exists()); + assert!(!moved_project.join(MANIFEST_FILE).exists()); + assert!(moved_project.join("backend").is_dir()); + assert!(fs::read_dir(&replacement_target).unwrap().next().is_none()); + } + + #[cfg(windows)] + #[test] + fn windows_pinned_root_blocks_replacement_until_the_lease_is_dropped() { + let temporary = TestDirectory::new(); + let project = temporary.path().join("project"); + let moved_project = temporary.path().join("moved-project"); + fs::create_dir(&project).unwrap(); + let root = PinnedRoot::open(&project).unwrap(); + + let error = fs::rename(&project, &moved_project).unwrap_err(); + assert!(matches!( + error.kind(), + io::ErrorKind::PermissionDenied | io::ErrorKind::Other + )); + assert!(project.is_dir()); + + drop(root); + fs::rename(&project, &moved_project).unwrap(); + fs::create_dir(&project).unwrap(); + assert!(fs::read_dir(&project).unwrap().next().is_none()); + assert!(moved_project.is_dir()); + } + + #[test] + fn prepared_target_mismatch_fails_before_mutation() { + let temporary = TestDirectory::new(); + let destination = temporary.path().join("demo"); + let plan = scaffold_plan(&destination, "demo", None).unwrap(); + let parent = PreparedWriteRoot::open(temporary.path()).unwrap(); + + let error = + apply_prepared_write_plan(&plan, PreparedWriteTarget::new_child(parent, "different")) + .unwrap_err(); + + assert_eq!(error.stage(), ApplyStage::ValidatePolicy); + assert_eq!(error.io_error().kind(), io::ErrorKind::InvalidInput); + assert!(error.rollback().is_complete()); + assert!(!destination.exists()); + } + + #[cfg(unix)] + #[test] + fn prepared_inventory_reads_the_retained_root_across_a_path_aba() { + let temporary = TestDirectory::new(); + let project = temporary.path().join("project"); + let moved_project = temporary.path().join("moved-project"); + fs::create_dir(&project).unwrap(); + fs::write(project.join("original.spock"), "").unwrap(); + let prepared = PreparedWriteRoot::open(&project).unwrap(); + + fs::rename(&project, &moved_project).unwrap(); + fs::create_dir(&project).unwrap(); + fs::write(project.join("foreign.spock"), "").unwrap(); + let inventory = prepared.inventory().unwrap(); + + fs::remove_dir_all(&project).unwrap(); + fs::rename(&moved_project, &project).unwrap(); + prepared.validate().unwrap(); + let paths = inventory + .entries() + .map(|(path, _)| path.as_str()) + .collect::>(); + assert_eq!(paths, ["original.spock"]); + } + + #[cfg(unix)] + #[test] + fn prepared_inventory_preserves_unsupported_entry_kinds() { + use std::os::unix::net::UnixListener; + + let temporary = TestDirectory::new(); + let socket_path = temporary.path().join("app.spock"); + let _socket = UnixListener::bind(&socket_path).unwrap(); + let prepared = PreparedWriteRoot::open(temporary.path()).unwrap(); + + let inventory = prepared.inventory().unwrap(); + let socket = NormalizedRelativePath::file("app.spock").unwrap(); + assert_eq!( + inventory.kind(&socket), + Some(InventoryEntryKind::Unsupported) + ); + } + + #[cfg(windows)] + #[test] + fn windows_prepared_parent_and_created_entries_stay_leased_through_commit() { + let temporary = TestDirectory::new(); + let workspace = temporary.path().join("workspace"); + let moved_workspace = temporary.path().join("moved-workspace"); + fs::create_dir(&workspace).unwrap(); + let parent = PreparedWriteRoot::open(&workspace).unwrap(); + assert!(fs::rename(&workspace, &moved_workspace).is_err()); + + let destination = workspace.join("demo"); + let moved_root = workspace.join("moved-demo"); + let moved_backend = destination.join("moved-backend"); + let moved_file = destination.join("backend/moved-app.spock"); + let backend = destination.join("backend"); + let backend_file = backend.join("app.spock"); + let plan = scaffold_plan(&destination, "demo", None).unwrap(); + let mut root_rename_blocked = false; + let mut directory_rename_blocked = false; + let mut file_rename_blocked = false; + + let summary = apply_write_plan_inner_with_target( + &plan, + RootPolicy::NewDestination, + Some(PreparedWriteTarget::new_child(parent, "demo")), + |written| { + if written == backend_file { + root_rename_blocked = fs::rename(&destination, &moved_root).is_err(); + directory_rename_blocked = fs::rename(&backend, &moved_backend).is_err(); + file_rename_blocked = fs::rename(&backend_file, &moved_file).is_err(); + } + }, + ) + .unwrap(); + + assert!(root_rename_blocked); + assert!(directory_rename_blocked); + assert!(file_rename_blocked); + assert_eq!(summary.root(), destination); + assert!(backend_file.is_file()); + assert!(destination.join(MANIFEST_FILE).is_file()); + } + + #[test] + fn root_policy_mismatch_fails_before_mutation() { + let temporary = TestDirectory::new(); + let destination = temporary.path().join("demo"); + let plan = scaffold_plan(&destination, "demo", None).unwrap(); + + let error = apply_write_plan(&plan, RootPolicy::ExistingAdoptionRoot).unwrap_err(); + + assert_eq!(error.stage(), ApplyStage::ValidatePolicy); + assert_eq!(error.io_error().kind(), io::ErrorKind::InvalidInput); + assert!(error.rollback().is_complete()); + assert!(!destination.exists()); + } +} diff --git a/crates/spock-cli/tests/cli.rs b/crates/spock-cli/tests/cli.rs index 0b6bf9e..f1a131e 100644 --- a/crates/spock-cli/tests/cli.rs +++ b/crates/spock-cli/tests/cli.rs @@ -3,6 +3,9 @@ use assert_cmd::Command; use predicates::prelude::*; +#[cfg(unix)] +use assert_cmd::prelude::CommandCargoExt; + fn spock() -> Command { Command::cargo_bin("spock").expect("binary builds") } @@ -13,6 +16,68 @@ fn write_program(dir: &std::path::Path, source: &str) -> std::path::PathBuf { path } +#[cfg(unix)] +fn assert_sigterm_shutdown(cwd: &std::path::Path, command_name: &str, arguments: &[&str]) { + use std::net::{TcpListener, TcpStream}; + use std::process::Stdio; + use std::thread; + use std::time::{Duration, Instant}; + + let probe = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = probe.local_addr().unwrap().port(); + drop(probe); + + let mut command = std::process::Command::cargo_bin("spock").unwrap(); + let mut child = command + .current_dir(cwd) + .arg(command_name) + .args(arguments) + .args(["--port", &port.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + + let ready_deadline = Instant::now() + Duration::from_secs(5); + loop { + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + break; + } + if let Some(status) = child.try_wait().unwrap() { + panic!("server exited before readiness: {status}"); + } + if Instant::now() >= ready_deadline { + child.kill().ok(); + child.wait().ok(); + panic!("server did not become ready"); + } + thread::sleep(Duration::from_millis(25)); + } + + let signal = std::process::Command::new("kill") + .args(["-TERM", &child.id().to_string()]) + .status() + .unwrap(); + assert!(signal.success()); + + let shutdown_deadline = Instant::now() + Duration::from_secs(2); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= shutdown_deadline { + child.kill().ok(); + child.wait().ok(); + panic!("server did not terminate after SIGTERM"); + } + thread::sleep(Duration::from_millis(25)); + }; + assert!(status.success(), "server exited with {status}"); + + let rebound = TcpListener::bind(("127.0.0.1", port)).unwrap(); + drop(rebound); +} + #[test] fn check_accepts_a_valid_program() { let dir = std::env::temp_dir().join("spock-cli-test-ok"); @@ -48,6 +113,52 @@ fn check_renders_diagnostics_and_fails() { .stderr(predicate::str::contains("app.spock:1:")); } +#[test] +fn relative_standalone_diagnostics_keep_the_caller_spelling() { + let temporary = tempfile::tempdir().unwrap(); + write_program(temporary.path(), "table a { x: nope }"); + + spock() + .current_dir(temporary.path()) + .args(["check", "app.spock"]) + .assert() + .failure() + .stderr(predicate::str::starts_with("app.spock:1:")) + .stderr(predicate::str::contains(temporary.path().to_string_lossy().as_ref()).not()); +} + +#[cfg(unix)] +#[test] +fn standalone_check_resolves_seed_assets_beside_a_symlink_spelling() { + use std::os::unix::fs::symlink; + + let temporary = tempfile::tempdir().unwrap(); + let source_root = temporary.path().join("source"); + let invocation_root = temporary.path().join("invocation"); + std::fs::create_dir(&source_root).unwrap(); + std::fs::create_dir(&invocation_root).unwrap(); + std::fs::write( + source_root.join("app.spock"), + "auth table user { key id: uuid = auto\n \ + username: text unique\n avatar: storage_object? }\n\ + seed { u = user { username: \"u\", avatar: file(\"./pic.png\") } }\n", + ) + .unwrap(); + std::fs::write( + invocation_root.join("pic.png"), + b"\x89PNG\r\n\x1a\nseed-bytes", + ) + .unwrap(); + symlink("../source/app.spock", invocation_root.join("app.spock")).unwrap(); + + spock() + .current_dir(invocation_root) + .args(["check", "app.spock"]) + .assert() + .success() + .stdout(predicate::str::contains("1 seed row(s)")); +} + #[test] fn check_is_the_full_load_proof() { // `check` now materializes in memory, so a body that compiles but @@ -99,5 +210,148 @@ fn missing_file_is_a_clean_error() { .args(["check", "/definitely/not/a/file.spock"]) .assert() .failure() - .stderr(predicate::str::contains("could not read")); + .stderr(predicate::str::starts_with("error: could not read")) + .stderr(predicate::str::contains("error: error:").not()); +} + +#[test] +fn help_exposes_framework_commands_and_retained_file_tools() { + spock() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("new")) + .stdout(predicate::str::contains("init")) + .stdout(predicate::str::contains("start")) + .stdout(predicate::str::contains("dev")) + .stdout(predicate::str::contains("run")) + .stdout(predicate::str::contains("build")) + .stdout(predicate::str::contains("gen")); +} + +#[test] +fn new_defaults_to_a_checkable_full_stack_project() { + let temporary = tempfile::tempdir().unwrap(); + + spock() + .current_dir(temporary.path()) + .args(["new", "demo"]) + .assert() + .success() + .stdout(predicate::str::contains( + "created full-stack project `demo`", + )); + + let project = temporary.path().join("demo"); + assert!(project.join("spock.toml").is_file()); + assert!(project.join("backend/app.spock").is_file()); + assert!(project.join("client/uhura.toml").is_file()); + spock() + .current_dir(&project) + .arg("check") + .assert() + .success() + .stdout(predicate::str::contains("ok: project `demo`")); +} + +#[test] +fn backend_only_new_omits_the_client_tree() { + let temporary = tempfile::tempdir().unwrap(); + + spock() + .current_dir(temporary.path()) + .args(["new", "authority", "--backend-only"]) + .assert() + .success() + .stdout(predicate::str::contains( + "created backend-only project `authority`", + )); + + let project = temporary.path().join("authority"); + assert!(!project.join("client").exists()); + spock() + .current_dir(project) + .arg("check") + .assert() + .success() + .stdout(predicate::str::contains("backend only")); +} + +#[test] +fn next_step_text_is_not_an_executable_command() { + let temporary = tempfile::tempdir().unwrap(); + + spock() + .current_dir(temporary.path()) + .args(["new", "demo;touch PWN", "--backend-only"]) + .assert() + .success() + .stdout(predicate::str::contains( + "next: run `spock dev` from the project directory above", + )) + .stdout(predicate::str::contains("next: cd").not()); + + assert!(!temporary.path().join("PWN").exists()); +} + +#[test] +fn project_check_deduplicates_shared_editor_and_play_diagnostics() { + let temporary = tempfile::tempdir().unwrap(); + spock() + .current_dir(temporary.path()) + .args(["new", "demo"]) + .assert() + .success(); + std::fs::write( + temporary.path().join("demo/client/app/home/page.uhura"), + "not valid uhura\n", + ) + .unwrap(); + + let assertion = spock() + .current_dir(temporary.path().join("demo")) + .arg("check") + .assert() + .failure(); + let stderr = String::from_utf8(assertion.get_output().stderr.clone()).unwrap(); + let repeated = "`not` is not a definition kind"; + assert_eq!(stderr.matches(repeated).count(), 1, "{stderr}"); + assert!(stderr.contains("app/home/page.uhura:"), "{stderr}"); +} + +#[test] +fn framework_serve_commands_reject_explicit_file_mode() { + let temporary = tempfile::tempdir().unwrap(); + let source = write_program(temporary.path(), ""); + + for command in ["start", "dev"] { + spock() + .current_dir(temporary.path()) + .args([command, source.to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("spock run")); + } +} + +#[cfg(unix)] +#[test] +fn standalone_sigterm_shutdown_is_clean_and_releases_the_port() { + let temporary = tempfile::tempdir().unwrap(); + write_program(temporary.path(), ""); + + assert_sigterm_shutdown(temporary.path(), "run", &["app.spock"]); +} + +#[cfg(unix)] +#[test] +fn framework_sigterm_shutdown_is_clean_and_releases_the_port() { + let temporary = tempfile::tempdir().unwrap(); + spock() + .current_dir(temporary.path()) + .args(["new", "authority", "--backend-only"]) + .assert() + .success(); + + assert_sigterm_shutdown(&temporary.path().join("authority"), "start", &[]); } diff --git a/crates/spock-host/.gitignore b/crates/spock-host/.gitignore new file mode 100644 index 0000000..b83d222 --- /dev/null +++ b/crates/spock-host/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/crates/spock-host/Cargo.toml b/crates/spock-host/Cargo.toml new file mode 100644 index 0000000..6c94da3 --- /dev/null +++ b/crates/spock-host/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "spock-host" +description = "Project generation coordination and the combined Spock framework host" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +spock-lang = { path = "../spock-lang" } +spock-project = { path = "../spock-project" } +spock-runtime = { path = "../spock-runtime" } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +axum.workspace = true +tokio.workspace = true +tokio-stream.workspace = true +sha2.workspace = true +unicode-normalization.workspace = true +uuid.workspace = true +uhura-host = { path = "../../uhura/crates/uhura-host" } +cap-fs-ext = "4.0.2" +cap-std = "4.0.2" + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1.1", features = ["fs"] } + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } + +[dev-dependencies] +tempfile.workspace = true +reqwest.workspace = true +tower = { version = "0.5", features = ["util"] } diff --git a/crates/spock-host/src/assets.rs b/crates/spock-host/src/assets.rs new file mode 100644 index 0000000..6fc51c2 --- /dev/null +++ b/crates/spock-host/src/assets.rs @@ -0,0 +1,1065 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::{BufReader, Read}, + path::{Component, Path, PathBuf}, +}; + +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use uhura_host::WebAssets; + +pub const SPOCK_UHURA_WEB_DIST: &str = "SPOCK_UHURA_WEB_DIST"; +pub const SPOCK_UHURA_WASM_DIST: &str = "SPOCK_UHURA_WASM_DIST"; + +// This value is captured by rustc, not read from the process environment. +// Official distribution builds bind it to the exact sidecar manifest produced +// earlier in the same release workflow. Source/test builds intentionally leave +// it unset and use the explicit paired asset-root override above. +const PACKAGED_UHURA_MANIFEST_SHA256: Option<&str> = + option_env!("SPOCK_PACKAGED_UHURA_MANIFEST_SHA256"); +const SIDECAR_PROTOCOL: &str = "spock-asset-sidecar/1"; +const HOST_ENVIRONMENT_PROTOCOL: &str = "spock-host-environment/1"; +const PROJECT_STATUS_PROTOCOL: &str = "spock-project-status/1"; +const PROJECT_EVENT_PROTOCOL: &str = "spock-project-event/1"; +const EDITOR_STATE_PROTOCOL: &str = "uhura-editor-state/1"; +const EDITOR_EVENT_PROTOCOL: &str = "uhura-editor-event/0"; +const IR_PROTOCOL: &str = "uhura-ir/0"; +const INSPECT_PROTOCOL: &str = "uhura-inspect/0"; +const VIEW_PROTOCOL: &str = "uhura-view/0"; +const PROVIDER_PROTOCOL: &str = "uhura-provider/0"; +const SIDECAR_PROTOCOLS: [(&str, &str); 9] = [ + ("environment", HOST_ENVIRONMENT_PROTOCOL), + ("project_status", PROJECT_STATUS_PROTOCOL), + ("project_event", PROJECT_EVENT_PROTOCOL), + ("editor_state", EDITOR_STATE_PROTOCOL), + ("editor_event", EDITOR_EVENT_PROTOCOL), + ("ir", IR_PROTOCOL), + ("inspect", INSPECT_PROTOCOL), + ("view", VIEW_PROTOCOL), + ("provider", PROVIDER_PROTOCOL), +]; +const MAX_MANIFEST_BYTES: u64 = 8 * 1024 * 1024; +const REQUIRED_FILES: [&str; 3] = [ + "web/index.html", + "wasm/uhura_wasm.js", + "wasm/uhura_wasm_bg.wasm", +]; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UhuraAssetRoots { + pub web: PathBuf, + pub wasm: PathBuf, +} + +impl UhuraAssetRoots { + pub fn load(&self) -> Result { + WebAssets::from_directories(&self.web, &self.wasm).map_err(|message| { + AssetError::InvalidBundle { + roots: self.clone(), + message, + } + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum AssetError { + #[error("{variable} must be set together with {other}")] + PartialOverride { + variable: &'static str, + other: &'static str, + }, + #[error( + "could not locate the packaged Uhura web and Wasm bundles (looked in {attempted}); \ + set {SPOCK_UHURA_WEB_DIST} and {SPOCK_UHURA_WASM_DIST} for a source/test override" + )] + NotFound { attempted: String }, + #[error( + "invalid Uhura asset bundle at web={} wasm={}: {message}", + roots.web.display(), + roots.wasm.display() + )] + InvalidBundle { + roots: UhuraAssetRoots, + message: String, + }, + #[error("invalid packaged Uhura asset sidecar: {message}")] + InvalidSidecar { message: String }, + #[error("could not resolve the current executable while locating Uhura assets: {0}")] + Executable(std::io::Error), +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SidecarManifest { + protocol: String, + spock_commit: String, + uhura_commit: String, + protocols: BTreeMap, + files: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SidecarFile { + path: String, + sha256: String, + size: u64, +} + +#[derive(Debug, Eq, PartialEq)] +struct ObservedFile { + path: String, + sha256: String, + size: u64, +} + +struct LocatedUhuraAssets { + roots: UhuraAssetRoots, + manifest: Option, +} + +/// The reusable `uhura-host` never searches a checkout. This aggregate-host +/// adapter checks an explicit paired override, executable-relative package +/// locations. Source builds opt in through the paired environment override; +/// `uhura-host` itself never searches a checkout. +/// +/// Load the exact package-owned bytes whose manifest is bound to this +/// executable and whose inventory matches that manifest. Explicit development +/// overrides have no package identity and are still captured once into the +/// same immutable [`WebAssets`] representation. +/// +/// The executable binding detects a changed or coherently replaced sidecar +/// while the binary remains trusted. It is not a signature over the binary and +/// cannot defend against replacement of both artifacts or a compromised build. +pub fn load_uhura_assets() -> Result { + let located = locate_uhura_asset_source()?; + let assets = located.roots.load()?; + if let Some(manifest) = located.manifest { + validate_loaded_assets(&manifest, &assets) + .map_err(|message| AssetError::InvalidSidecar { message })?; + } + Ok(assets) +} + +fn locate_uhura_asset_source() -> Result { + let web_override = std::env::var_os(SPOCK_UHURA_WEB_DIST).map(PathBuf::from); + let wasm_override = std::env::var_os(SPOCK_UHURA_WASM_DIST).map(PathBuf::from); + match (web_override, wasm_override) { + (Some(web), Some(wasm)) => { + return Ok(LocatedUhuraAssets { + roots: UhuraAssetRoots { web, wasm }, + manifest: None, + }); + } + (Some(_), None) => { + return Err(AssetError::PartialOverride { + variable: SPOCK_UHURA_WEB_DIST, + other: SPOCK_UHURA_WASM_DIST, + }); + } + (None, Some(_)) => { + return Err(AssetError::PartialOverride { + variable: SPOCK_UHURA_WASM_DIST, + other: SPOCK_UHURA_WEB_DIST, + }); + } + (None, None) => {} + } + + let executable = std::env::current_exe().map_err(AssetError::Executable)?; + let mut candidates = Vec::new(); + if let Some(bin) = executable.parent() { + // Conventional prefix install: /bin/spock plus + // /share/spock/uhura/{web,wasm}. + let root = bin.join("../share/spock/uhura"); + candidates.push(( + root.clone(), + UhuraAssetRoots { + web: root.join("web"), + wasm: root.join("wasm"), + }, + )); + // npm: /binaries//spock plus one shared sidecar. + let root = bin.join("../../share/spock/uhura"); + candidates.push(( + root.clone(), + UhuraAssetRoots { + web: root.join("web"), + wasm: root.join("wasm"), + }, + )); + } + let mut attempted = Vec::new(); + let mut invalid = Vec::new(); + for (root, roots) in candidates { + if attempted.contains(&roots) { + continue; + } + let manifest = root.join("manifest.json"); + let mut candidate_present = false; + for path in [&manifest, &roots.web, &roots.wasm] { + match fs::symlink_metadata(path) { + Ok(_) => candidate_present = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + candidate_present = true; + invalid.push(format!("{}: {error}", path.display())); + } + } + } + if candidate_present { + match validate_packaged_sidecar(&root, &roots) { + Ok(manifest) => { + return Ok(LocatedUhuraAssets { + roots, + manifest: Some(manifest), + }); + } + Err(message) => invalid.push(format!("{}: {message}", root.display())), + } + } + attempted.push(roots); + } + + if !invalid.is_empty() { + return Err(AssetError::InvalidSidecar { + message: invalid.join("; "), + }); + } + + Err(AssetError::NotFound { + attempted: attempted + .iter() + .map(|roots| format!("{} + {}", roots.web.display(), roots.wasm.display())) + .collect::>() + .join(", "), + }) +} + +fn validate_packaged_sidecar( + sidecar_root: &Path, + roots: &UhuraAssetRoots, +) -> Result { + let expected_manifest_sha256 = + require_packaged_manifest_sha256(PACKAGED_UHURA_MANIFEST_SHA256)?; + validate_packaged_sidecar_with_digest(sidecar_root, roots, expected_manifest_sha256) +} + +fn validate_packaged_sidecar_with_digest( + sidecar_root: &Path, + roots: &UhuraAssetRoots, + expected_manifest_sha256: &str, +) -> Result { + let root_metadata = fs::symlink_metadata(sidecar_root) + .map_err(|error| format!("could not inspect {}: {error}", sidecar_root.display()))?; + if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() { + return Err(format!( + "sidecar root {} must be a real directory, not a symlink or special file", + sidecar_root.display() + )); + } + + let expected_roots = UhuraAssetRoots { + web: sidecar_root.join("web"), + wasm: sidecar_root.join("wasm"), + }; + if roots != &expected_roots { + return Err("asset roots do not belong to the declared sidecar root".to_owned()); + } + + let manifest_path = sidecar_root.join("manifest.json"); + let manifest_metadata = symlink_free_file_metadata(&manifest_path, "manifest")?; + if manifest_metadata.len() > MAX_MANIFEST_BYTES { + return Err(format!( + "manifest is {} bytes; maximum is {MAX_MANIFEST_BYTES}", + manifest_metadata.len() + )); + } + let manifest_bytes = fs::read(&manifest_path) + .map_err(|error| format!("could not read {}: {error}", manifest_path.display()))?; + validate_manifest_binding(&manifest_bytes, expected_manifest_sha256)?; + let manifest: SidecarManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("could not parse {}: {error}", manifest_path.display()))?; + + validate_manifest_header(&manifest)?; + validate_manifest_files(&manifest.files)?; + + let mut observed = Vec::new(); + collect_observed_files(sidecar_root, &roots.web, &mut observed)?; + collect_observed_files(sidecar_root, &roots.wasm, &mut observed)?; + observed.sort_by(|left, right| left.path.cmp(&right.path)); + validate_case_insensitive_uniqueness(observed.iter().map(|file| file.path.as_str()))?; + + if manifest.files.len() != observed.len() { + return Err(format!( + "manifest lists {} files but the sidecar contains {} files", + manifest.files.len(), + observed.len() + )); + } + for (declared, actual) in manifest.files.iter().zip(&observed) { + if declared.path != actual.path { + return Err(format!( + "file inventory mismatch: manifest has {} where sidecar has {}", + declared.path, actual.path + )); + } + if declared.size != actual.size { + return Err(format!( + "size mismatch for {}: manifest has {} but sidecar has {}", + declared.path, declared.size, actual.size + )); + } + if declared.sha256 != actual.sha256 { + return Err(format!( + "SHA-256 mismatch for {}: manifest has {} but sidecar has {}", + declared.path, declared.sha256, actual.sha256 + )); + } + } + + for required in REQUIRED_FILES { + if observed + .binary_search_by_key(&required, |file| file.path.as_str()) + .is_err() + { + return Err(format!("required sidecar file {required} is missing")); + } + } + + Ok(manifest) +} + +fn require_packaged_manifest_sha256(configured: Option<&str>) -> Result<&str, String> { + let configured = configured.ok_or_else(|| { + format!( + "this executable has no trusted Uhura sidecar manifest identity; packaged sidecar loading is disabled (source/test builds must set the paired {SPOCK_UHURA_WEB_DIST} and {SPOCK_UHURA_WASM_DIST} overrides)" + ) + })?; + if !lowercase_sha256(configured) { + return Err( + "the executable's trusted Uhura sidecar manifest identity is not a 64-character lowercase SHA-256 digest" + .to_owned(), + ); + } + Ok(configured) +} + +fn validate_manifest_binding( + manifest_bytes: &[u8], + expected_manifest_sha256: &str, +) -> Result<(), String> { + if !lowercase_sha256(expected_manifest_sha256) { + return Err("trusted sidecar manifest SHA-256 is malformed".to_owned()); + } + let observed = sha256_bytes(manifest_bytes); + if observed != expected_manifest_sha256 { + return Err(format!( + "manifest SHA-256 {observed} does not match executable-bound identity {expected_manifest_sha256}" + )); + } + Ok(()) +} + +fn lowercase_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn validate_loaded_assets(manifest: &SidecarManifest, assets: &WebAssets) -> Result<(), String> { + let observed = assets.inventory(); + if manifest.files.len() != observed.len() { + return Err(format!( + "manifest lists {} files but the immutable asset snapshot contains {} files", + manifest.files.len(), + observed.len() + )); + } + for (declared, actual) in manifest.files.iter().zip(&observed) { + if declared.path != actual.path { + return Err(format!( + "file inventory mismatch: manifest has {} where the immutable asset snapshot has {}", + declared.path, actual.path + )); + } + if declared.size != actual.size { + return Err(format!( + "size mismatch for {}: manifest has {} but the immutable asset snapshot has {}", + declared.path, declared.size, actual.size + )); + } + if declared.sha256 != actual.sha256 { + return Err(format!( + "SHA-256 mismatch for {}: manifest has {} but the immutable asset snapshot has {}", + declared.path, declared.sha256, actual.sha256 + )); + } + } + Ok(()) +} + +fn validate_manifest_header(manifest: &SidecarManifest) -> Result<(), String> { + if manifest.protocol != SIDECAR_PROTOCOL { + return Err(format!( + "unsupported manifest protocol {}; expected {SIDECAR_PROTOCOL}", + manifest.protocol + )); + } + for (name, commit) in [ + ("spock_commit", manifest.spock_commit.as_str()), + ("uhura_commit", manifest.uhura_commit.as_str()), + ] { + if commit.len() != 40 + || !commit + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "manifest {name} must be exactly 40 lowercase hexadecimal characters" + )); + } + } + for (name, expected) in SIDECAR_PROTOCOLS { + match manifest.protocols.get(name) { + Some(actual) if actual == expected => {} + Some(actual) => { + return Err(format!("protocol {name} is {actual}; expected {expected}")); + } + None => return Err(format!("required protocol {name} is missing")), + } + } + for name in manifest.protocols.keys() { + if !SIDECAR_PROTOCOLS + .iter() + .any(|(expected, _)| name == expected) + { + return Err(format!("unsupported protocol key {name}")); + } + } + Ok(()) +} + +fn validate_manifest_files(files: &[SidecarFile]) -> Result<(), String> { + let mut previous: Option<&str> = None; + for file in files { + validate_relative_asset_path(&file.path)?; + if let Some(previous) = previous { + if previous >= file.path.as_str() { + let reason = if previous == file.path { + "contains a duplicate" + } else { + "is not sorted" + }; + return Err(format!("manifest file inventory {reason} at {}", file.path)); + } + } + previous = Some(&file.path); + if file.size == 0 { + return Err(format!("manifest size for {} must be positive", file.path)); + } + if file.sha256.len() != 64 + || !file + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(format!( + "manifest SHA-256 for {} must be 64 lowercase hexadecimal characters", + file.path + )); + } + } + validate_case_insensitive_uniqueness(files.iter().map(|file| file.path.as_str())) +} + +fn validate_relative_asset_path(path: &str) -> Result<(), String> { + if path.is_empty() || path.contains('\\') { + return Err(format!("unsafe manifest file path {path:?}")); + } + let segments = path.split('/').collect::>(); + if segments.len() < 2 + || !matches!(segments.first(), Some(&"web" | &"wasm")) + || segments + .iter() + .any(|segment| !portable_asset_segment(segment)) + || Path::new(path) + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(format!("unsafe manifest file path {path:?}")); + } + Ok(()) +} + +fn portable_asset_segment(segment: &str) -> bool { + let bytes = segment.as_bytes(); + bytes.first().is_some_and(u8::is_ascii_alphanumeric) + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + && !segment.ends_with('.') + && !windows_device_segment(segment) +} + +fn windows_device_segment(segment: &str) -> bool { + let stem = segment.split_once('.').map_or(segment, |(stem, _)| stem); + if stem.eq_ignore_ascii_case("con") + || stem.eq_ignore_ascii_case("prn") + || stem.eq_ignore_ascii_case("aux") + || stem.eq_ignore_ascii_case("nul") + { + return true; + } + let bytes = stem.as_bytes(); + bytes.len() == 4 + && (bytes[..3].eq_ignore_ascii_case(b"com") || bytes[..3].eq_ignore_ascii_case(b"lpt")) + && matches!(bytes[3], b'1'..=b'9') +} + +fn validate_case_insensitive_uniqueness<'a>( + paths: impl Iterator, +) -> Result<(), String> { + let mut folded = BTreeSet::new(); + for path in paths { + if !folded.insert(path.to_lowercase()) { + return Err(format!( + "sidecar contains a case-insensitive path collision at {path}" + )); + } + } + Ok(()) +} + +fn collect_observed_files( + sidecar_root: &Path, + directory: &Path, + observed: &mut Vec, +) -> Result<(), String> { + let metadata = fs::symlink_metadata(directory) + .map_err(|error| format!("could not inspect {}: {error}", directory.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!( + "{} must be a real directory, not a symlink or special file", + directory.display() + )); + } + + let entries = fs::read_dir(directory) + .map_err(|error| format!("could not read {}: {error}", directory.display()))?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "could not read an entry in {}: {error}", + directory.display() + ) + })?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path) + .map_err(|error| format!("could not inspect {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "sidecar path {} must not be a symlink", + path.display() + )); + } + if metadata.is_dir() { + collect_observed_files(sidecar_root, &path, observed)?; + continue; + } + if !metadata.is_file() { + return Err(format!( + "sidecar path {} is not a regular file", + path.display() + )); + } + if metadata.len() == 0 { + return Err(format!("sidecar file {} must not be empty", path.display())); + } + let relative = path.strip_prefix(sidecar_root).map_err(|_| { + format!( + "sidecar path {} escaped root {}", + path.display(), + sidecar_root.display() + ) + })?; + let relative = manifest_path(relative)?; + validate_relative_asset_path(&relative)?; + observed.push(ObservedFile { + path: relative, + sha256: hash_file(&path)?, + size: metadata.len(), + }); + } + Ok(()) +} + +fn manifest_path(relative: &Path) -> Result { + let mut segments = Vec::new(); + for component in relative.components() { + let Component::Normal(segment) = component else { + return Err(format!( + "sidecar path {} is not a safe relative path", + relative.display() + )); + }; + segments.push( + segment + .to_str() + .ok_or_else(|| { + format!( + "sidecar path {} cannot be represented as UTF-8", + relative.display() + ) + })? + .to_owned(), + ); + } + Ok(segments.join("/")) +} + +fn symlink_free_file_metadata(path: &Path, label: &str) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("could not inspect {label} {}: {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "{label} {} must be a regular file, not a symlink", + path.display() + )); + } + Ok(metadata) +} + +fn hash_file(path: &Path) -> Result { + let file = fs::File::open(path) + .map_err(|error| format!("could not open {} for hashing: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = reader + .read(&mut buffer) + .map_err(|error| format!("could not hash {}: {error}", path.display()))?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + use tempfile::tempdir; + + // Most validation tests deliberately mutate one manifest field and need + // the executable binding to follow that fixture so they can reach the + // lower-level protocol or inventory assertion under test. + fn validate_packaged_sidecar( + sidecar_root: &Path, + roots: &UhuraAssetRoots, + ) -> Result { + let expected = hash_file(&sidecar_root.join("manifest.json"))?; + validate_packaged_sidecar_with_digest(sidecar_root, roots, &expected) + } + + #[test] + fn explicit_asset_roots_are_snapshotted_as_one_bundle() { + let temp = tempdir().expect("temporary asset root"); + let web = temp.path().join("web"); + let wasm = temp.path().join("wasm"); + std::fs::create_dir_all(web.join("assets")).expect("web assets"); + std::fs::create_dir_all(&wasm).expect("wasm directory"); + std::fs::write( + web.join("index.html"), + r#""#, + ) + .expect("web index"); + std::fs::write(web.join("assets/app.js"), "export {};\n").expect("web script"); + std::fs::write(wasm.join("uhura_wasm.js"), "export {};\n").expect("wasm loader"); + std::fs::write(wasm.join("uhura_wasm_bg.wasm"), b"wasm").expect("wasm module"); + + let roots = UhuraAssetRoots { web, wasm }; + let assets = roots.load().expect("valid immutable web assets"); + drop(assets); + } + + #[test] + fn packaged_sidecar_accepts_an_exact_manifest() { + let fixture = SidecarFixture::new(); + + validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect("exact sidecar manifest should validate"); + } + + #[test] + fn packaged_sidecar_requires_an_executable_bound_manifest_identity() { + let error = require_packaged_manifest_sha256(None) + .expect_err("a source binary must not accept an executable-relative sidecar"); + assert!( + error.contains("no trusted Uhura sidecar manifest identity"), + "{error}" + ); + + let error = require_packaged_manifest_sha256(Some("not-a-digest")) + .expect_err("a malformed build identity must fail closed"); + assert!( + error.contains("not a 64-character lowercase SHA-256"), + "{error}" + ); + } + + #[test] + fn executable_binding_rejects_a_coherently_rehashed_sidecar() { + let mut fixture = SidecarFixture::new(); + let trusted = fixture.manifest_sha256(); + + fs::write( + fixture.root.join("web/assets/app.js"), + "export const coherently_rehashed = true;\n", + ) + .expect("replace asset bytes"); + fixture.refresh_manifest_entry("web/assets/app.js"); + fixture.write_manifest(); + + validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect("the rewritten manifest remains internally consistent"); + let error = validate_packaged_sidecar_with_digest(&fixture.root, &fixture.roots, &trusted) + .expect_err("the executable must retain the original manifest identity"); + assert!( + error.contains("does not match executable-bound identity"), + "{error}" + ); + } + + #[test] + fn packaged_sidecar_rejects_protocol_mismatches() { + let mut fixture = SidecarFixture::new(); + fixture.manifest["protocol"] = json!("spock-asset-sidecar/2"); + fixture.write_manifest(); + + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("unknown sidecar protocol must fail closed"); + assert!(error.contains("unsupported manifest protocol"), "{error}"); + + fixture.manifest["protocol"] = json!(SIDECAR_PROTOCOL); + fixture.manifest["protocols"]["project_event"] = json!("wrong/1"); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("framework protocol mismatch must fail closed"); + assert!(error.contains("protocol project_event"), "{error}"); + + let mut fixture = SidecarFixture::new(); + fixture.manifest["protocols"] + .as_object_mut() + .expect("protocol object") + .remove("provider"); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("missing protocol must fail closed"); + assert!( + error.contains("required protocol provider is missing"), + "{error}" + ); + + let mut fixture = SidecarFixture::new(); + fixture.manifest["protocols"]["future"] = json!("future/1"); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("unknown protocol key must fail closed"); + assert!(error.contains("unsupported protocol key future"), "{error}"); + } + + #[test] + fn packaged_sidecar_requires_full_git_object_ids() { + let mut fixture = SidecarFixture::new(); + fixture.manifest["spock_commit"] = json!("unknown"); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("non-object commit must fail closed"); + assert!(error.contains("spock_commit must be exactly 40"), "{error}"); + + fixture.manifest["spock_commit"] = json!("ABCDEF0123456789abcdef0123456789abcdef01"); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("uppercase object id must fail closed"); + assert!(error.contains("spock_commit must be exactly 40"), "{error}"); + } + + #[test] + fn packaged_sidecar_rejects_unsafe_duplicate_and_unsorted_paths() { + let mut fixture = SidecarFixture::new(); + fixture.manifest["files"][0]["path"] = json!("web/../escape"); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("unsafe path must fail closed"); + assert!(error.contains("unsafe manifest file path"), "{error}"); + + let mut fixture = SidecarFixture::new(); + let duplicate = fixture.manifest["files"][0].clone(); + fixture.manifest["files"] + .as_array_mut() + .expect("file array") + .insert(1, duplicate); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("duplicate path must fail closed"); + assert!(error.contains("contains a duplicate"), "{error}"); + + let mut fixture = SidecarFixture::new(); + fixture.manifest["files"] + .as_array_mut() + .expect("file array") + .reverse(); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("unsorted paths must fail closed"); + assert!(error.contains("is not sorted"), "{error}"); + } + + #[test] + fn packaged_sidecar_paths_use_the_portable_ascii_grammar() { + for path in [ + "web/café.js", + "web/.hidden.js", + "web/-app.js", + "web/trailing.", + "web/CON", + "web/com1.js", + "wasm/LPT9.bin", + ] { + let error = validate_relative_asset_path(path) + .expect_err("non-portable package path must fail closed"); + assert!( + error.contains("unsafe manifest file path"), + "{path}: {error}" + ); + } + for path in [ + "web/index.html", + "web/assets/app-ABC_123.js", + "wasm/uhura_wasm_bg.wasm", + "web/console.js", + "web/com10.js", + ] { + validate_relative_asset_path(path) + .unwrap_or_else(|error| panic!("{path} should be portable: {error}")); + } + } + + #[test] + fn packaged_sidecar_rejects_missing_extra_and_case_colliding_files() { + let fixture = SidecarFixture::new(); + fs::remove_file(fixture.root.join("web/assets/app.js")).expect("remove declared file"); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("missing file must fail closed"); + assert!(error.contains("manifest lists"), "{error}"); + + let fixture = SidecarFixture::new(); + fs::write(fixture.root.join("web/extra.js"), "extra\n").expect("extra file"); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("unlisted file must fail closed"); + assert!(error.contains("manifest lists"), "{error}"); + + let mut fixture = SidecarFixture::new(); + let mut collision = fixture.manifest["files"] + .as_array() + .expect("file array") + .iter() + .find(|file| file["path"] == "web/index.html") + .expect("index manifest entry") + .clone(); + collision["path"] = json!("web/INDEX.html"); + let files = fixture.manifest["files"] + .as_array_mut() + .expect("file array"); + files.push(collision); + files.sort_by(|left, right| { + left["path"] + .as_str() + .expect("path") + .cmp(right["path"].as_str().expect("path")) + }); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("case-insensitive collision must fail closed"); + assert!(error.contains("case-insensitive path collision"), "{error}"); + } + + #[test] + fn packaged_sidecar_rejects_size_and_hash_mismatches() { + let mut fixture = SidecarFixture::new(); + fixture.manifest["files"][0]["size"] = json!(999); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("size mismatch must fail closed"); + assert!(error.contains("size mismatch"), "{error}"); + + let mut fixture = SidecarFixture::new(); + fixture.manifest["files"][0]["sha256"] = + json!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + fixture.write_manifest(); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("hash mismatch must fail closed"); + assert!(error.contains("SHA-256 mismatch"), "{error}"); + } + + #[test] + fn manifest_integrity_covers_the_exact_immutable_bytes_that_will_be_served() { + let fixture = SidecarFixture::new(); + let manifest = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect("initial sidecar should validate"); + let captured = fixture.roots.load().expect("capture validated assets"); + + fs::write( + fixture.root.join("web/assets/app.js"), + "export const tampered = true;\n", + ) + .expect("mutate source after capture"); + + validate_loaded_assets(&manifest, &captured) + .expect("filesystem mutation cannot alter an immutable captured snapshot"); + let tampered = fixture.roots.load().expect("capture mutated assets"); + let error = validate_loaded_assets(&manifest, &tampered) + .expect_err("mutated captured bytes must fail manifest integrity validation"); + assert!(error.contains("mismatch"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn packaged_sidecar_rejects_symlinks() { + use std::os::unix::fs::symlink; + + let fixture = SidecarFixture::new(); + symlink( + fixture.root.join("web/index.html"), + fixture.root.join("web/link.html"), + ) + .expect("asset symlink"); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("asset symlink must fail closed"); + assert!(error.contains("must not be a symlink"), "{error}"); + + let fixture = SidecarFixture::new(); + fs::rename( + fixture.root.join("manifest.json"), + fixture.root.join("real-manifest.json"), + ) + .expect("move manifest"); + symlink( + fixture.root.join("real-manifest.json"), + fixture.root.join("manifest.json"), + ) + .expect("manifest symlink"); + let error = validate_packaged_sidecar(&fixture.root, &fixture.roots) + .expect_err("manifest symlink must fail closed"); + assert!(error.contains("must be a regular file"), "{error}"); + } + + struct SidecarFixture { + _temp: tempfile::TempDir, + root: PathBuf, + roots: UhuraAssetRoots, + manifest: Value, + } + + impl SidecarFixture { + fn new() -> Self { + let temp = tempdir().expect("temporary sidecar"); + let root = temp.path().join("uhura"); + let roots = UhuraAssetRoots { + web: root.join("web"), + wasm: root.join("wasm"), + }; + fs::create_dir_all(roots.web.join("assets")).expect("web directories"); + fs::create_dir_all(&roots.wasm).expect("wasm directory"); + fs::write( + roots.web.join("index.html"), + r#""#, + ) + .expect("web index"); + fs::write(roots.web.join("assets/app.js"), "export {};\n").expect("web script"); + fs::write(roots.wasm.join("uhura_wasm.js"), "export {};\n").expect("wasm loader"); + fs::write(roots.wasm.join("uhura_wasm_bg.wasm"), b"wasm").expect("wasm module"); + + let mut files = Vec::new(); + for relative in [ + "wasm/uhura_wasm.js", + "wasm/uhura_wasm_bg.wasm", + "web/assets/app.js", + "web/index.html", + ] { + let path = root.join(relative); + files.push(json!({ + "path": relative, + "sha256": hash_file(&path).expect("fixture hash"), + "size": fs::metadata(path).expect("fixture metadata").len(), + })); + } + files.sort_by(|left, right| { + left["path"] + .as_str() + .expect("path") + .cmp(right["path"].as_str().expect("path")) + }); + let manifest = json!({ + "protocol": SIDECAR_PROTOCOL, + "spock_commit": "0123456789abcdef0123456789abcdef01234567", + "uhura_commit": "89abcdef0123456789abcdef0123456789abcdef", + "protocols": { + "environment": HOST_ENVIRONMENT_PROTOCOL, + "project_status": PROJECT_STATUS_PROTOCOL, + "project_event": PROJECT_EVENT_PROTOCOL, + "editor_state": EDITOR_STATE_PROTOCOL, + "editor_event": EDITOR_EVENT_PROTOCOL, + "ir": IR_PROTOCOL, + "inspect": INSPECT_PROTOCOL, + "view": VIEW_PROTOCOL, + "provider": PROVIDER_PROTOCOL + }, + "files": files, + }); + let fixture = Self { + _temp: temp, + root, + roots, + manifest, + }; + fixture.write_manifest(); + fixture + } + + fn write_manifest(&self) { + fs::write( + self.root.join("manifest.json"), + serde_json::to_vec_pretty(&self.manifest).expect("serialize fixture manifest"), + ) + .expect("write fixture manifest"); + } + + fn manifest_sha256(&self) -> String { + hash_file(&self.root.join("manifest.json")).expect("hash fixture manifest") + } + + fn refresh_manifest_entry(&mut self, relative: &str) { + let path = self.root.join(relative); + let entry = self.manifest["files"] + .as_array_mut() + .expect("file array") + .iter_mut() + .find(|entry| entry["path"] == relative) + .expect("manifest entry"); + entry["sha256"] = json!(hash_file(&path).expect("asset hash")); + entry["size"] = json!(fs::metadata(path).expect("asset metadata").len()); + } + } +} diff --git a/crates/spock-host/src/backend_capture.rs b/crates/spock-host/src/backend_capture.rs new file mode 100644 index 0000000..12b0cd6 --- /dev/null +++ b/crates/spock-host/src/backend_capture.rs @@ -0,0 +1,1182 @@ +//! Coherent, database-free observation of Spock backend inputs. +//! +//! The observer captures the configured source and every checked +//! `file("...")` seed dependency as one immutable byte bundle. It never opens a +//! runtime generation or touches a database; activation policy belongs to the +//! generation coordinator and process supervisor. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::fs; +use std::io::{self, Read}; +use std::ops::Range; +use std::path::{Path, PathBuf}; + +use cap_fs_ext::{DirExt as _, FollowSymlinks, OpenOptionsFollowExt as _, OpenOptionsSyncExt as _}; +use spock_lang::ir::SeedValue; +use spock_project::ProjectLayout; +use spock_runtime::generation::CapturedBackend; + +use crate::Fingerprint; + +const MAX_STABILITY_SAMPLES: usize = 4; +const INVALID_OBSERVATION_PROTOCOL: &[u8] = b"spock-invalid-backend-observation/1"; + +/// Stable categories for backend-capture failures. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BackendDiagnosticCode { + Io, + InvalidUtf8, + Language, + PathEscape, + WrongEntryKind, + UnstableInputs, +} + +impl BackendDiagnosticCode { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Io => "SPH001", + Self::InvalidUtf8 => "SPH002", + Self::Language => "SPH003", + Self::PathEscape => "SPH004", + Self::WrongEntryKind => "SPH005", + Self::UnstableInputs => "SPH006", + } + } +} + +impl fmt::Display for BackendDiagnosticCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// One diagnostic from a coherent backend observation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackendDiagnostic { + pub code: BackendDiagnosticCode, + pub message: String, + pub path: Option, + pub span: Option>, + /// The Spock language diagnostic code when `code` is `Language`. + pub language_code: Option<&'static str>, +} + +impl BackendDiagnostic { + fn new(code: BackendDiagnosticCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + path: None, + span: None, + language_code: None, + } + } + + fn at_path(mut self, path: impl Into) -> Self { + self.path = Some(path.into()); + self + } + + fn stable_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + push_field(&mut bytes, self.code.as_str().as_bytes()); + push_field( + &mut bytes, + self.path + .as_deref() + .unwrap_or_else(|| Path::new("")) + .as_os_str() + .to_string_lossy() + .as_bytes(), + ); + push_field( + &mut bytes, + self.language_code.unwrap_or_default().as_bytes(), + ); + if let Some(span) = &self.span { + push_field(&mut bytes, &span.start.to_be_bytes()); + push_field(&mut bytes, &span.end.to_be_bytes()); + } else { + push_field(&mut bytes, &[]); + push_field(&mut bytes, &[]); + } + push_field(&mut bytes, self.message.as_bytes()); + bytes + } +} + +impl fmt::Display for BackendDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: ", self.code)?; + if let Some(path) = &self.path { + write!(formatter, "{}: ", path.display())?; + } + if let Some(language_code) = self.language_code { + write!(formatter, "error[{language_code}]: ")?; + } + formatter.write_str(&self.message) + } +} + +/// Deterministically ordered diagnostics from one backend observation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BackendDiagnostics(Vec); + +impl BackendDiagnostics { + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + #[must_use] + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn iter(&self) -> impl ExactSizeIterator { + self.0.iter() + } + + fn push(&mut self, diagnostic: BackendDiagnostic) { + self.0.push(diagnostic); + } +} + +impl fmt::Display for BackendDiagnostics { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, diagnostic) in self.0.iter().enumerate() { + if index != 0 { + formatter.write_str("\n")?; + } + diagnostic.fmt(formatter)?; + } + Ok(()) + } +} + +impl std::error::Error for BackendDiagnostics {} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ObservedInput { + display_name: String, + fingerprint: Fingerprint, +} + +/// One stable filesystem observation, valid or invalid. +/// +/// Invalid source states still have an identity, allowing `spock dev` to +/// report transitions and recovery without constructing a database-backed +/// runtime candidate. +#[derive(Clone, Debug)] +pub struct BackendObservation { + fingerprint: Fingerprint, + inputs: BTreeMap, + captured: Option, + diagnostics: BackendDiagnostics, +} + +impl BackendObservation { + #[must_use] + pub fn fingerprint(&self) -> &Fingerprint { + &self.fingerprint + } + + #[must_use] + pub fn captured_backend(&self) -> Option<&CapturedBackend> { + self.captured.as_ref() + } + + #[must_use] + pub fn diagnostics(&self) -> &BackendDiagnostics { + &self.diagnostics + } + + #[must_use] + pub fn is_valid(&self) -> bool { + self.captured.is_some() + } + + /// Human-facing names for inputs whose value or availability differs. + #[must_use] + pub fn changed_inputs_since(&self, previous: &Self) -> Vec { + let identities = self + .inputs + .keys() + .chain(previous.inputs.keys()) + .collect::>(); + identities + .into_iter() + .filter_map(|identity| { + let current = self.inputs.get(identity); + let previous = previous.inputs.get(identity); + (current != previous).then(|| { + current + .or(previous) + .expect("identity came from one input map") + .display_name + .clone() + }) + }) + .collect::>() + .into_iter() + .collect() + } + + pub fn into_captured_backend(self) -> Result { + match self.captured { + Some(captured) => Ok(captured), + None => Err(self.diagnostics), + } + } +} + +/// Observe the backend without constructing, opening, reseeding, or swapping +/// any runtime generation. +#[must_use] +pub fn observe_backend(layout: &ProjectLayout) -> BackendObservation { + let sample = stable_sample(|| sample_backend(layout)); + BackendObservation::from_sample(sample) +} + +/// Capture a valid immutable runtime input bundle. +pub fn capture_backend(layout: &ProjectLayout) -> Result { + observe_backend(layout).into_captured_backend() +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum InputContent { + Bytes(Vec), + Unavailable(String), +} + +impl InputContent { + fn stable_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + match self { + Self::Bytes(value) => { + bytes.extend_from_slice(b"bytes"); + push_field(&mut bytes, value); + } + Self::Unavailable(reason) => { + bytes.extend_from_slice(b"unavailable"); + push_field(&mut bytes, reason.as_bytes()); + } + } + bytes + } + + fn bytes(&self) -> Option<&[u8]> { + match self { + Self::Bytes(bytes) => Some(bytes), + Self::Unavailable(_) => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct SampleInput { + display_name: String, + requested_path: PathBuf, + canonical_path: Option, + content: InputContent, +} + +impl SampleInput { + fn unavailable( + display_name: impl Into, + requested_path: PathBuf, + reason: impl Into, + ) -> Self { + Self { + display_name: display_name.into(), + requested_path, + canonical_path: None, + content: InputContent::Unavailable(reason.into()), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct BackendSample { + source: SampleInput, + assets: BTreeMap, + diagnostics: BackendDiagnostics, +} + +impl BackendSample { + fn new(layout: &ProjectLayout, source_path: PathBuf) -> Self { + Self { + source: SampleInput::unavailable( + layout.backend_entry.relative().as_str(), + source_path, + "source has not been read", + ), + assets: BTreeMap::new(), + diagnostics: BackendDiagnostics::default(), + } + } + + fn source_failure(mut self, code: BackendDiagnosticCode, message: impl Into) -> Self { + let message = message.into(); + self.source.content = InputContent::Unavailable(message.clone()); + self.diagnostics.push( + BackendDiagnostic::new(code, message).at_path(self.source.requested_path.clone()), + ); + self + } +} + +impl BackendObservation { + fn from_sample(sample: BackendSample) -> Self { + let valid = sample.diagnostics.is_empty(); + let source_bytes = sample.source.content.bytes().unwrap_or_default(); + let asset_bytes = sample + .assets + .iter() + .filter_map(|(spelling, input)| { + input + .content + .bytes() + .map(|bytes| (spelling.clone(), bytes.to_vec())) + }) + .collect::>(); + + let captured_candidate = CapturedBackend::new(source_bytes, asset_bytes); + let fingerprint = if valid { + Fingerprint::new(captured_candidate.input_fingerprint().as_str()) + } else { + invalid_observation_fingerprint(&sample) + }; + + let mut inputs = BTreeMap::new(); + inputs.insert( + format!("source:{}", sample.source.display_name), + observed_input("source", &sample.source), + ); + for (spelling, input) in &sample.assets { + inputs.insert( + format!("seed:{spelling}"), + observed_input(&format!("seed:{spelling}"), input), + ); + } + + Self { + fingerprint, + inputs, + captured: valid.then_some(captured_candidate), + diagnostics: sample.diagnostics, + } + } +} + +fn observed_input(identity: &str, input: &SampleInput) -> ObservedInput { + let captured = CapturedBackend::new( + [], + BTreeMap::from([(identity.to_string(), input.content.stable_bytes())]), + ); + ObservedInput { + display_name: input.display_name.clone(), + fingerprint: Fingerprint::new(captured.input_fingerprint().as_str()), + } +} + +fn invalid_observation_fingerprint(sample: &BackendSample) -> Fingerprint { + let mut source = INVALID_OBSERVATION_PROTOCOL.to_vec(); + push_field(&mut source, &sample.source.content.stable_bytes()); + for diagnostic in sample.diagnostics.iter() { + push_field(&mut source, &diagnostic.stable_bytes()); + } + let assets = sample + .assets + .iter() + .map(|(spelling, input)| (spelling.clone(), input.content.stable_bytes())) + .collect(); + let synthetic = CapturedBackend::new(source, assets); + Fingerprint::new(synthetic.input_fingerprint().as_str()) +} + +fn stable_sample(mut sample: impl FnMut() -> BackendSample) -> BackendSample { + let mut previous = sample(); + for _ in 1..MAX_STABILITY_SAMPLES { + let current = sample(); + if current == previous { + return current; + } + previous = current; + } + previous.diagnostics.push( + BackendDiagnostic::new( + BackendDiagnosticCode::UnstableInputs, + format!( + "backend inputs did not remain unchanged across {MAX_STABILITY_SAMPLES} consecutive samples" + ), + ) + .at_path(previous.source.requested_path.clone()), + ); + previous +} + +#[derive(Debug)] +enum ConfinedReadError { + Io(io::Error), + WrongEntryKind, +} + +/// A retained directory capability used to open every backend input. +/// +/// Callers first canonicalize for the existing user-facing containment policy, +/// then traverse that canonical relative path without following any component. +/// This preserves in-root symlink spellings while preventing an ancestor +/// symlink or reparse-point swap from redirecting reads outside the validated +/// root. +#[derive(Debug)] +struct ConfinedDirectory { + directory: cap_std::fs::Dir, +} + +impl ConfinedDirectory { + fn open_ambient_nofollow(path: &Path) -> io::Result { + let file = open_directory_path_nofollow(path)?; + let directory = cap_std::fs::Dir::from_std_file(file); + if !directory.dir_metadata()?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "confined root is not a real directory", + )); + } + Ok(Self { directory }) + } + + fn open_directory(&self, relative: &Path) -> io::Result { + let mut current = self.directory.try_clone()?; + for component in relative.components() { + let std::path::Component::Normal(segment) = component else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "confined directory path is not normalized and relative", + )); + }; + current = current.open_dir_nofollow(segment)?; + } + Ok(Self { directory: current }) + } + + fn read_regular_file(&self, relative: &Path) -> Result, ConfinedReadError> { + let file_name = relative.file_name().ok_or_else(|| { + ConfinedReadError::Io(io::Error::new( + io::ErrorKind::InvalidInput, + "confined file path has no file name", + )) + })?; + let parent = self + .open_directory(relative.parent().unwrap_or_else(|| Path::new(""))) + .map_err(ConfinedReadError::Io)?; + let initial_metadata = parent + .directory + .symlink_metadata(file_name) + .map_err(ConfinedReadError::Io)?; + if !initial_metadata.is_file() && !initial_metadata.file_type().is_symlink() { + return Err(ConfinedReadError::WrongEntryKind); + } + let mut options = cap_std::fs::OpenOptions::new(); + options + .read(true) + .follow(FollowSymlinks::No) + // A special entry installed after the metadata probe must not turn + // capture into a blocking FIFO/device open. The returned handle is + // still the authority for the regular-file check below. + .nonblock(true); + let mut file = match parent.directory.open_with(file_name, &options) { + Ok(file) => file, + Err(error) => { + if parent + .directory + .symlink_metadata(file_name) + .is_ok_and(|metadata| !metadata.is_file() && !metadata.file_type().is_symlink()) + { + return Err(ConfinedReadError::WrongEntryKind); + } + return Err(ConfinedReadError::Io(error)); + } + }; + let metadata = file.metadata().map_err(ConfinedReadError::Io)?; + if !metadata.is_file() { + return Err(ConfinedReadError::WrongEntryKind); + } + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(ConfinedReadError::Io)?; + Ok(bytes) + } +} + +#[cfg(unix)] +fn open_directory_path_nofollow(path: &Path) -> io::Result { + if !path.is_absolute() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "confined root path is not absolute", + )); + } + let descriptor = rustix::fs::openat( + rustix::fs::CWD, + Path::new("/"), + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + )?; + let mut current = cap_std::fs::Dir::from_std_file(fs::File::from(descriptor)); + for component in path.components() { + match component { + std::path::Component::RootDir => {} + std::path::Component::Normal(segment) => { + current = current.open_dir_nofollow(segment)?; + } + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "confined root path is not canonical", + )); + } + } + } + Ok(current.into_std_file()) +} + +#[cfg(windows)] +fn open_directory_path_nofollow(path: &Path) -> io::Result { + use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _}; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let mut components = path.components(); + let Some(std::path::Component::Prefix(prefix)) = components.next() else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "confined root path has no volume prefix", + )); + }; + if !matches!(components.next(), Some(std::path::Component::RootDir)) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "confined root path is not absolute", + )); + } + let mut volume_root = PathBuf::from(prefix.as_os_str()); + volume_root.push(Path::new(r"\")); + let file = fs::OpenOptions::new() + .read(true) + // Denying delete sharing keeps each retained directory from being + // renamed while the next child is resolved through it. + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(&volume_root)?; + let metadata = file.metadata()?; + if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "confined root is not a real directory", + )); + } + let mut current = cap_std::fs::Dir::from_std_file(file); + for component in components { + let std::path::Component::Normal(segment) = component else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "confined root path is not canonical", + )); + }; + current = current.open_dir_nofollow(segment)?; + } + Ok(current.into_std_file()) +} + +#[cfg(not(any(unix, windows)))] +fn open_directory_path_nofollow(path: &Path) -> io::Result { + let metadata = fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::NotADirectory, + "confined root is not a real directory", + )); + } + cap_std::fs::Dir::open_ambient_dir(path, cap_std::ambient_authority()) + .map(cap_std::fs::Dir::into_std_file) +} + +fn relative_to<'a>(path: &'a Path, root: &Path) -> io::Result<&'a Path> { + path.strip_prefix(root).map_err(|_| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "validated path is outside its confined root", + ) + }) +} + +fn confined_failure_code(requested: &Path, allowed_root: &Path) -> BackendDiagnosticCode { + match fs::canonicalize(requested) { + Ok(current) if !current.starts_with(allowed_root) => BackendDiagnosticCode::PathEscape, + _ => BackendDiagnosticCode::Io, + } +} + +fn sample_backend(layout: &ProjectLayout) -> BackendSample { + let source_requested = layout.root.join(layout.backend_entry.relative().as_path()); + let mut sample = BackendSample::new(layout, source_requested.clone()); + + let canonical_root = match fs::canonicalize(&layout.root) { + Ok(path) => path, + Err(error) => { + return sample.source_failure( + BackendDiagnosticCode::Io, + format!("could not resolve project root: {error}"), + ); + } + }; + if !canonical_root.is_dir() { + return sample.source_failure( + BackendDiagnosticCode::WrongEntryKind, + "project root is not a directory", + ); + } + let project_directory = match ConfinedDirectory::open_ambient_nofollow(&canonical_root) { + Ok(directory) => directory, + Err(error) => { + return sample.source_failure( + confined_failure_code(&layout.root, &canonical_root), + format!("could not securely open project root: {error}"), + ); + } + }; + + let backend_root_requested = canonical_root.join(layout.backend_root.relative().as_path()); + let canonical_backend_root = match fs::canonicalize(&backend_root_requested) { + Ok(path) => path, + Err(error) => { + return sample.source_failure( + BackendDiagnosticCode::Io, + format!("could not resolve configured backend root: {error}"), + ); + } + }; + if !canonical_backend_root.starts_with(&canonical_root) { + return sample.source_failure( + BackendDiagnosticCode::PathEscape, + "configured backend root resolves outside the project root", + ); + } + if !canonical_backend_root.is_dir() { + return sample.source_failure( + BackendDiagnosticCode::WrongEntryKind, + "configured backend root is not a directory", + ); + } + let backend_relative = match relative_to(&canonical_backend_root, &canonical_root) { + Ok(relative) => relative, + Err(error) => { + return sample.source_failure( + BackendDiagnosticCode::PathEscape, + format!("configured backend root escaped the project root: {error}"), + ); + } + }; + let backend_directory = match project_directory.open_directory(backend_relative) { + Ok(directory) => directory, + Err(error) => { + return sample.source_failure( + confined_failure_code(&backend_root_requested, &canonical_root), + format!("could not securely open configured backend root: {error}"), + ); + } + }; + + let source_directory_requested = source_requested + .parent() + .unwrap_or(canonical_backend_root.as_path()); + let canonical_source_directory = match fs::canonicalize(source_directory_requested) { + Ok(path) => path, + Err(error) => { + return sample.source_failure( + BackendDiagnosticCode::Io, + format!("could not resolve backend source directory: {error}"), + ); + } + }; + if !canonical_source_directory.starts_with(&canonical_backend_root) { + return sample.source_failure( + BackendDiagnosticCode::PathEscape, + "backend source directory resolves outside the configured backend root", + ); + } + let source_directory_relative = + match relative_to(&canonical_source_directory, &canonical_backend_root) { + Ok(relative) => relative, + Err(error) => { + return sample.source_failure( + BackendDiagnosticCode::PathEscape, + format!( + "backend source directory escaped the configured backend root: {error}" + ), + ); + } + }; + let source_directory = match backend_directory.open_directory(source_directory_relative) { + Ok(directory) => directory, + Err(error) => { + return sample.source_failure( + confined_failure_code(source_directory_requested, &canonical_backend_root), + format!("could not securely open backend source directory: {error}"), + ); + } + }; + + let canonical_source = match fs::canonicalize(&source_requested) { + Ok(path) => path, + Err(error) => { + return sample.source_failure( + BackendDiagnosticCode::Io, + format!("could not resolve backend entry: {error}"), + ); + } + }; + if !canonical_source.starts_with(&canonical_backend_root) { + return sample.source_failure( + BackendDiagnosticCode::PathEscape, + "backend entry resolves outside the configured backend root", + ); + } + let source_relative = match relative_to(&canonical_source, &canonical_backend_root) { + Ok(relative) => relative, + Err(error) => { + return sample.source_failure( + BackendDiagnosticCode::PathEscape, + format!("backend entry escaped the configured backend root: {error}"), + ); + } + }; + let source = match backend_directory.read_regular_file(source_relative) { + Ok(source) => source, + Err(ConfinedReadError::WrongEntryKind) => { + return sample.source_failure( + BackendDiagnosticCode::WrongEntryKind, + "configured backend entry is not a regular file", + ); + } + Err(ConfinedReadError::Io(error)) => { + let code = confined_failure_code(&source_requested, &canonical_backend_root); + let message = if code == BackendDiagnosticCode::PathEscape { + "backend entry changed to resolve outside the configured backend root".to_string() + } else { + format!("could not securely read backend entry: {error}") + }; + return sample.source_failure(code, message); + } + }; + sample.source = SampleInput { + display_name: layout.backend_entry.relative().as_str().to_string(), + requested_path: source_requested.clone(), + canonical_path: Some(canonical_source), + content: InputContent::Bytes(source.clone()), + }; + + let source_text = match std::str::from_utf8(&source) { + Ok(source) => source, + Err(error) => { + sample.diagnostics.push( + BackendDiagnostic::new( + BackendDiagnosticCode::InvalidUtf8, + format!("backend entry is not UTF-8: {error}"), + ) + .at_path(source_requested), + ); + return sample; + } + }; + let contract = match spock_lang::compile(source_text) { + Ok(contract) => contract, + Err(diagnostics) => { + for diagnostic in diagnostics { + sample.diagnostics.push(BackendDiagnostic { + code: BackendDiagnosticCode::Language, + message: diagnostic.message, + path: Some(source_requested.clone()), + span: Some(diagnostic.span.start..diagnostic.span.end), + language_code: Some(diagnostic.code), + }); + } + return sample; + } + }; + + let asset_spellings = contract + .seed + .iter() + .flat_map(|row| row.fields.values()) + .filter_map(|value| match value { + SeedValue::File { path } => Some(path.clone()), + _ => None, + }) + .collect::>(); + + for spelling in asset_spellings { + let display_name = format!("seed asset `{spelling}`"); + let requested = source_directory_requested.join(Path::new(&spelling)); + let canonical = match fs::canonicalize(&requested) { + Ok(path) => path, + Err(error) => { + let message = format!("could not resolve seed asset `{spelling}`: {error}"); + sample.assets.insert( + spelling, + SampleInput::unavailable(display_name, requested.clone(), &message), + ); + sample.diagnostics.push( + BackendDiagnostic::new(BackendDiagnosticCode::Io, message).at_path(requested), + ); + continue; + } + }; + if !canonical.starts_with(&canonical_source_directory) + || !canonical.starts_with(&canonical_backend_root) + || !canonical.starts_with(&canonical_root) + { + let message = + format!("seed asset `{spelling}` resolves outside the backend source directory"); + let mut input = + SampleInput::unavailable(display_name, requested.clone(), message.clone()); + input.canonical_path = Some(canonical); + sample.assets.insert(spelling, input); + sample.diagnostics.push( + BackendDiagnostic::new(BackendDiagnosticCode::PathEscape, message) + .at_path(requested), + ); + continue; + } + let asset_relative = match relative_to(&canonical, &canonical_source_directory) { + Ok(relative) => relative, + Err(error) => { + let message = + format!("seed asset `{spelling}` escaped its source directory: {error}"); + let mut input = + SampleInput::unavailable(display_name, requested.clone(), message.clone()); + input.canonical_path = Some(canonical); + sample.assets.insert(spelling, input); + sample.diagnostics.push( + BackendDiagnostic::new(BackendDiagnosticCode::PathEscape, message) + .at_path(requested), + ); + continue; + } + }; + match source_directory.read_regular_file(asset_relative) { + Ok(bytes) => { + sample.assets.insert( + spelling, + SampleInput { + display_name, + requested_path: requested, + canonical_path: Some(canonical), + content: InputContent::Bytes(bytes), + }, + ); + } + Err(ConfinedReadError::WrongEntryKind) => { + let message = format!("seed asset `{spelling}` is not a regular file"); + let mut input = + SampleInput::unavailable(display_name, requested.clone(), message.clone()); + input.canonical_path = Some(canonical); + sample.assets.insert(spelling, input); + sample.diagnostics.push( + BackendDiagnostic::new(BackendDiagnosticCode::WrongEntryKind, message) + .at_path(requested), + ); + } + Err(ConfinedReadError::Io(error)) => { + let code = confined_failure_code(&requested, &canonical_source_directory); + let message = if code == BackendDiagnosticCode::PathEscape { + format!("seed asset `{spelling}` changed to resolve outside the backend source directory") + } else { + format!("could not securely read seed asset `{spelling}`: {error}") + }; + let mut input = + SampleInput::unavailable(display_name, requested.clone(), message.clone()); + input.canonical_path = Some(canonical); + sample.assets.insert(spelling, input); + sample + .diagnostics + .push(BackendDiagnostic::new(code, message).at_path(requested)); + } + } + } + + sample +} + +fn push_field(output: &mut Vec, bytes: &[u8]) { + output.extend_from_slice(&(bytes.len() as u64).to_be_bytes()); + output.extend_from_slice(bytes); +} + +#[cfg(test)] +mod tests { + use super::*; + use spock_project::{load_project_from, ProjectManifest, MANIFEST_FILE}; + use tempfile::tempdir; + + const STORAGE_SOURCE: &str = "auth table user { key id: uuid = auto\n \ + username: text unique\n avatar: storage_object? }\n\ + seed { user { username: \"u\", avatar: file(\"./seed/pic.png\") } }\n"; + + fn project(source: &[u8]) -> (tempfile::TempDir, ProjectLayout) { + let temp = tempdir().expect("temp project"); + fs::create_dir(temp.path().join("backend")).expect("backend directory"); + fs::write(temp.path().join("backend/app.spock"), source).expect("backend source"); + let manifest = ProjectManifest::new("demo", "backend", "app.spock", None) + .expect("manifest") + .to_toml_string(); + fs::write(temp.path().join(MANIFEST_FILE), manifest).expect("project manifest"); + let layout = load_project_from(temp.path()).expect("project layout"); + (temp, layout) + } + + #[test] + fn empty_backend_captures_without_constructing_a_runtime_generation() { + let (_temp, layout) = project(b"// intentionally empty\n"); + + let observation = observe_backend(&layout); + + assert!(observation.is_valid(), "{}", observation.diagnostics()); + let captured = observation.captured_backend().expect("captured backend"); + assert_eq!(captured.source(), b"// intentionally empty\n"); + assert_eq!( + observation.fingerprint().as_str(), + captured.input_fingerprint().as_str() + ); + } + + #[test] + fn seed_assets_are_captured_and_participate_in_change_detection() { + let (temp, layout) = project(STORAGE_SOURCE.as_bytes()); + fs::create_dir(temp.path().join("backend/seed")).expect("seed directory"); + let asset = temp.path().join("backend/seed/pic.png"); + fs::write(&asset, b"first").expect("first asset"); + let first = observe_backend(&layout); + + fs::write(&asset, b"second").expect("second asset"); + let second = observe_backend(&layout); + + assert!(first.is_valid(), "{}", first.diagnostics()); + assert!(second.is_valid(), "{}", second.diagnostics()); + assert_eq!( + first + .captured_backend() + .expect("first capture") + .seed_asset("./seed/pic.png"), + Some(b"first".as_slice()) + ); + assert_ne!(first.fingerprint(), second.fingerprint()); + assert_eq!( + second.changed_inputs_since(&first), + vec!["seed asset `./seed/pic.png`"] + ); + } + + #[test] + fn stable_invalid_source_has_diagnostics_and_a_recoverable_identity() { + let (temp, layout) = project(b"table broken {"); + let invalid = observe_backend(&layout); + + assert!(!invalid.is_valid()); + assert!(invalid + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == BackendDiagnosticCode::Language)); + + fs::write( + temp.path().join("backend/app.spock"), + b"table fixed { key id: uuid = auto }", + ) + .expect("fixed source"); + let fixed = observe_backend(&layout); + assert!(fixed.is_valid(), "{}", fixed.diagnostics()); + assert_ne!(invalid.fingerprint(), fixed.fingerprint()); + assert_eq!( + fixed.changed_inputs_since(&invalid), + vec!["backend/app.spock"] + ); + } + + #[test] + fn missing_seed_asset_is_invalid_and_recovers_when_the_file_appears() { + let (temp, layout) = project(STORAGE_SOURCE.as_bytes()); + fs::create_dir(temp.path().join("backend/seed")).expect("seed directory"); + let missing = observe_backend(&layout); + assert!(!missing.is_valid()); + + fs::write(temp.path().join("backend/seed/pic.png"), b"payload").expect("seed asset"); + let recovered = observe_backend(&layout); + + assert!(recovered.is_valid(), "{}", recovered.diagnostics()); + assert_ne!(missing.fingerprint(), recovered.fingerprint()); + assert_eq!( + recovered.changed_inputs_since(&missing), + vec!["seed asset `./seed/pic.png`"] + ); + } + + #[cfg(unix)] + #[test] + fn in_root_source_and_seed_symlinks_remain_supported() { + use std::os::unix::fs::symlink; + + let (temp, layout) = project(STORAGE_SOURCE.as_bytes()); + let backend = temp.path().join("backend"); + let source = backend.join("app.spock"); + let real_source = backend.join("real.spock"); + fs::write(&real_source, STORAGE_SOURCE).expect("real backend source"); + fs::remove_file(&source).expect("remove source fixture"); + symlink("real.spock", &source).expect("in-root source symlink"); + + fs::create_dir(backend.join("seed")).expect("seed directory"); + fs::write(backend.join("seed/real.png"), b"inside").expect("real seed asset"); + symlink("real.png", backend.join("seed/pic.png")).expect("in-root seed symlink"); + + let observation = observe_backend(&layout); + + assert!(observation.is_valid(), "{}", observation.diagnostics()); + let captured = observation.captured_backend().expect("captured backend"); + assert_eq!(captured.source(), STORAGE_SOURCE.as_bytes()); + assert_eq!( + captured.seed_asset("./seed/pic.png"), + Some(b"inside".as_slice()) + ); + } + + #[cfg(unix)] + #[test] + fn confined_reader_rejects_a_post_validation_symlink_swap() { + use std::os::unix::fs::symlink; + + let root = tempdir().expect("confined root"); + let outside = tempdir().expect("outside root"); + let input = root.path().join("input.bin"); + fs::write(&input, b"inside").expect("safe input"); + fs::write(outside.path().join("outside.bin"), b"outside").expect("outside input"); + + let canonical_root = fs::canonicalize(root.path()).expect("canonical root"); + let canonical_input = fs::canonicalize(&input).expect("validated input"); + let reader = ConfinedDirectory::open_ambient_nofollow(&canonical_root) + .expect("retained confined root"); + + fs::remove_file(&input).expect("remove validated input"); + symlink(outside.path().join("outside.bin"), &input).expect("escaping replacement"); + let relative = relative_to(&canonical_input, &canonical_root).expect("relative input"); + + assert!(matches!( + reader.read_regular_file(relative), + Err(ConfinedReadError::Io(_)) + )); + assert_eq!( + confined_failure_code(&input, &canonical_root), + BackendDiagnosticCode::PathEscape + ); + } + + #[cfg(unix)] + #[test] + fn confined_root_rejects_a_held_ancestor_redirect() { + use std::os::unix::fs::symlink; + + let parent = tempdir().expect("root parent"); + let outside = tempdir().expect("redirect target"); + let ancestor = parent.path().join("ancestor"); + let moved_ancestor = parent.path().join("moved-ancestor"); + let root = ancestor.join("project"); + fs::create_dir_all(&root).expect("original root"); + fs::create_dir(outside.path().join("project")).expect("redirected project root"); + let canonical_root = fs::canonicalize(&root).expect("canonical original root"); + + fs::rename(&ancestor, &moved_ancestor).expect("move original ancestor"); + symlink(outside.path(), &ancestor).expect("held ancestor redirect"); + assert_eq!( + fs::canonicalize(&canonical_root).expect("redirected canonical spelling"), + fs::canonicalize(outside.path().join("project")).expect("canonical redirect target") + ); + + ConfinedDirectory::open_ambient_nofollow(&canonical_root) + .expect_err("component-wise traversal must reject an ancestor redirect"); + } + + #[cfg(unix)] + #[test] + fn source_replaced_by_a_socket_is_rejected_as_non_regular_without_blocking() { + use std::os::unix::net::UnixListener; + + let (temp, layout) = project(b"// initially regular\n"); + let source = temp.path().join("backend/app.spock"); + fs::remove_file(&source).expect("remove source fixture"); + let _socket = UnixListener::bind(&source).expect("source socket"); + + let observation = observe_backend(&layout); + + assert!(!observation.is_valid()); + assert!(observation + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == BackendDiagnosticCode::WrongEntryKind)); + } + + #[cfg(unix)] + #[test] + fn seed_asset_symlink_cannot_escape_the_source_directory() { + use std::os::unix::fs::symlink; + + let (temp, layout) = project(STORAGE_SOURCE.as_bytes()); + let outside = tempdir().expect("outside directory"); + fs::write(outside.path().join("pic.png"), b"outside").expect("outside asset"); + fs::create_dir(temp.path().join("backend/seed")).expect("seed directory"); + symlink( + outside.path().join("pic.png"), + temp.path().join("backend/seed/pic.png"), + ) + .expect("escaping symlink"); + + let observation = observe_backend(&layout); + + assert!(!observation.is_valid()); + assert!(observation + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.code == BackendDiagnosticCode::PathEscape)); + } + + #[test] + fn changing_samples_are_not_published_as_a_coherent_capture() { + let (_temp, layout) = project(b""); + let source_path = layout.root.join(layout.backend_entry.relative().as_path()); + let samples = [b"a", b"b", b"c", b"d"].map(|bytes| BackendSample { + source: SampleInput { + display_name: "backend/app.spock".into(), + requested_path: source_path.clone(), + canonical_path: Some(source_path.clone()), + content: InputContent::Bytes(bytes.to_vec()), + }, + assets: BTreeMap::new(), + diagnostics: BackendDiagnostics::default(), + }); + let mut samples = samples.into_iter(); + + let sample = stable_sample(|| samples.next().expect("bounded sample")); + + assert!(sample + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == BackendDiagnosticCode::UnstableInputs)); + } +} diff --git a/crates/spock-host/src/client.rs b/crates/spock-host/src/client.rs new file mode 100644 index 0000000..dce6367 --- /dev/null +++ b/crates/spock-host/src/client.rs @@ -0,0 +1,426 @@ +use thiserror::Error; +use uhura_host::{ + build_candidate, CandidateDiagnostics, CandidateSummary, ClientCandidate as UhuraCandidate, + Host as UhuraHost, ProjectSourceSnapshot, PublicationReport, RouteRequest, RouteResponse, + WebAssets, +}; + +use crate::{Fingerprint, ObservedRevision}; + +/// Content identity of the exact Uhura snapshot consumed by a client build. +/// +/// Uhura remains responsible for enumerating and capturing its semantic +/// inputs. The framework only converts that subsystem-owned identity into its +/// status vocabulary. +#[must_use] +pub fn client_source_fingerprint(snapshot: &ProjectSourceSnapshot) -> Fingerprint { + Fingerprint::new(snapshot.fingerprint().stable_id()) +} + +/// One complete off-path Uhura build, bound to the framework observation that +/// requested it. +/// +/// `source_revision` is intentionally independent from `observed_revision`: +/// backend-only observations advance the framework clock without creating a +/// hole in Uhura's consecutive publication clock. +pub struct PreparedClient { + observed_revision: ObservedRevision, + source_revision: u64, + source_fingerprint: Fingerprint, + summary: CandidateSummary, + candidate: UhuraCandidate, +} + +impl PreparedClient { + fn build( + snapshot: &ProjectSourceSnapshot, + observed_revision: ObservedRevision, + source_revision: u64, + ) -> Self { + let source_fingerprint = client_source_fingerprint(snapshot); + let candidate = build_candidate(snapshot, source_revision); + let summary = candidate.summary(); + Self { + observed_revision, + source_revision, + source_fingerprint, + summary, + candidate, + } + } + + #[must_use] + pub const fn observed_revision(&self) -> ObservedRevision { + self.observed_revision + } + + #[must_use] + pub const fn source_revision(&self) -> u64 { + self.source_revision + } + + #[must_use] + pub fn source_fingerprint(&self) -> &Fingerprint { + &self.source_fingerprint + } + + #[must_use] + pub const fn summary(&self) -> CandidateSummary { + self.summary + } + + #[must_use] + pub fn diagnostics(&self) -> CandidateDiagnostics<'_> { + self.candidate.diagnostics() + } +} + +/// The last Uhura Play generation that successfully replaced served client +/// artifacts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ActiveClientBinding { + pub observed_revision: ObservedRevision, + pub source_revision: u64, + pub source_fingerprint: Fingerprint, + pub play_generation: u64, +} + +/// Result of publishing one Editor/Play attempt into the listenerless Uhura +/// host. +/// +/// An invalid attempt still advances Editor diagnostics and the Uhura source +/// revision. `active` continues to name the last successful Play generation, +/// if one exists. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientPublication { + pub observed_revision: ObservedRevision, + pub source_fingerprint: Fingerprint, + pub report: PublicationReport, + pub active: Option, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ClientHostError { + #[error( + "client candidate from observed revision {candidate} is stale; newest observed revision is {newest}" + )] + StaleCandidate { + candidate: ObservedRevision, + newest: ObservedRevision, + }, + #[error( + "client candidate repeats or precedes published observed revision {published}; received {received}" + )] + ObservationOrder { + published: ObservedRevision, + received: ObservedRevision, + }, + #[error( + "client candidate has Uhura source revision {received}; expected the next consecutive revision {expected}" + )] + PublicationOrder { expected: u64, received: u64 }, + #[error("Uhura host publication failed: {0}")] + Uhura(String), +} + +/// Framework-owned publication state around the reusable listenerless Uhura +/// host. +/// +/// This type does not observe the filesystem or decide whether a candidate is +/// the newest project observation. The future `dev` coordinator supplies that +/// eligibility fact at publication time, while this boundary enforces it and +/// translates between the two revision domains. +pub struct ClientHost { + host: UhuraHost, + latest: ClientPublication, +} + +impl ClientHost { + /// Build and publish Uhura revision 1 before a public listener is bound. + pub fn activate( + web: WebAssets, + snapshot: &ProjectSourceSnapshot, + observed_revision: ObservedRevision, + ) -> Result<(Self, ClientPublication), ClientHostError> { + let prepared = PreparedClient::build(snapshot, observed_revision, 1); + let source_fingerprint = prepared.source_fingerprint.clone(); + let (host, report) = + UhuraHost::new(web, prepared.candidate).map_err(ClientHostError::Uhura)?; + let latest = publication(observed_revision, source_fingerprint, report, None); + Ok(( + Self { + host, + latest: latest.clone(), + }, + latest, + )) + } + + /// Build the next candidate without mutating the served publication. + #[must_use] + pub fn prepare( + &self, + snapshot: &ProjectSourceSnapshot, + observed_revision: ObservedRevision, + ) -> PreparedClient { + PreparedClient::build(snapshot, observed_revision, self.host.source_revision() + 1) + } + + /// Publish only a candidate belonging to the newest framework observation. + /// + /// The caller passes the current observation after any off-path build has + /// completed. This second check makes an older result permanently + /// ineligible even when it finishes after newer work. + pub fn publish( + &mut self, + prepared: PreparedClient, + newest_observed_revision: ObservedRevision, + ) -> Result { + if prepared.observed_revision != newest_observed_revision { + return Err(ClientHostError::StaleCandidate { + candidate: prepared.observed_revision, + newest: newest_observed_revision, + }); + } + if prepared.observed_revision <= self.latest.observed_revision { + return Err(ClientHostError::ObservationOrder { + published: self.latest.observed_revision, + received: prepared.observed_revision, + }); + } + + let expected = self.host.source_revision() + 1; + if prepared.source_revision != expected { + return Err(ClientHostError::PublicationOrder { + expected, + received: prepared.source_revision, + }); + } + + let report = self + .host + .publish(prepared.candidate) + .map_err(ClientHostError::Uhura)?; + let latest = publication( + prepared.observed_revision, + prepared.source_fingerprint, + report, + self.latest.active.clone(), + ); + self.latest = latest.clone(); + Ok(latest) + } + + #[must_use] + pub fn latest_publication(&self) -> &ClientPublication { + &self.latest + } + + #[must_use] + pub fn active_client(&self) -> Option<&ActiveClientBinding> { + self.latest.active.as_ref() + } + + /// Delegate one transport-neutral request to the subsystem host. The + /// combined Axum adapter can translate the returned bytes or event stream + /// without giving Uhura its own listener. + #[must_use] + pub fn route(&self, request: RouteRequest<'_>) -> RouteResponse { + self.host.route(request) + } +} + +fn publication( + observed_revision: ObservedRevision, + source_fingerprint: Fingerprint, + report: PublicationReport, + previous_active: Option, +) -> ClientPublication { + let active = if report.play_ok { + Some(ActiveClientBinding { + observed_revision, + source_revision: report.source_revision, + source_fingerprint: source_fingerprint.clone(), + play_generation: report.play_generation, + }) + } else { + previous_active + }; + ClientPublication { + observed_revision, + source_fingerprint, + report, + active, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use uhura_host::{capture_project_snapshot, RequestMethod, RouteBody}; + + use super::*; + use crate::{GenerationCoordinator, HostMode, Observation}; + + struct TempDirectory(PathBuf); + + impl TempDirectory { + fn new(label: &str) -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + let path = std::env::temp_dir().join(format!( + "spock-host-{label}-{}-{unique}", + std::process::id() + )); + fs::create_dir_all(&path).expect("create temporary directory"); + Self(path) + } + } + + impl AsRef for TempDirectory { + fn as_ref(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDirectory { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn web_assets() -> (TempDirectory, WebAssets) { + let root = TempDirectory::new("web"); + fs::create_dir_all(root.as_ref().join("assets")).unwrap(); + fs::write( + root.as_ref().join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(root.as_ref().join("assets/app.js"), "export {};\n").unwrap(); + let assets = WebAssets::from_frontend_directory(root.as_ref()).unwrap(); + (root, assets) + } + + fn canonical_snapshot() -> ProjectSourceSnapshot { + capture_project_snapshot( + &Path::new(env!("CARGO_MANIFEST_DIR")).join("../../uhura/examples/instagram-uhura"), + ) + } + + fn coordinator() -> GenerationCoordinator { + GenerationCoordinator::activated( + HostMode::Dev, + Fingerprint::new("backend-a"), + Fingerprint::new("topology-a"), + Some(Fingerprint::new("client-a")), + "world-a", + ) + } + + fn advance_backend_only(coordinator: &mut GenerationCoordinator, name: &str) { + coordinator.observe(Observation { + topology: Fingerprint::new("topology-a"), + backend: Fingerprint::new(name), + client: Some(Fingerprint::new("client-a")), + changed_backend_inputs: vec!["backend/app.spock".to_owned()], + backend_diagnostics: Vec::new(), + }); + } + + #[test] + fn client_publication_clock_does_not_inherit_project_revision_gaps() { + let (_web_root, web) = web_assets(); + let snapshot = canonical_snapshot(); + let mut coordinator = coordinator(); + let (mut host, initial) = + ClientHost::activate(web, &snapshot, coordinator.observed_revision()).unwrap(); + assert_eq!(initial.report.source_revision, 1); + assert!(initial.active.is_some()); + + advance_backend_only(&mut coordinator, "backend-b"); + advance_backend_only(&mut coordinator, "backend-c"); + let observed_revision = coordinator.observed_revision(); + assert_eq!(observed_revision.get(), 3); + + let candidate = host.prepare(&snapshot, observed_revision); + assert_eq!(candidate.observed_revision(), observed_revision); + assert_eq!(candidate.source_revision(), 2); + let published = host.publish(candidate, observed_revision).unwrap(); + assert_eq!(published.observed_revision, observed_revision); + assert_eq!(published.report.source_revision, 2); + assert_eq!( + published.active.unwrap().observed_revision, + observed_revision + ); + } + + #[test] + fn stale_result_is_rejected_before_it_can_advance_uhura_state() { + let (_web_root, web) = web_assets(); + let snapshot = canonical_snapshot(); + let mut coordinator = coordinator(); + let (mut host, _) = + ClientHost::activate(web, &snapshot, coordinator.observed_revision()).unwrap(); + + advance_backend_only(&mut coordinator, "backend-b"); + let stale_revision = coordinator.observed_revision(); + let stale = host.prepare(&snapshot, stale_revision); + advance_backend_only(&mut coordinator, "backend-c"); + let newest_revision = coordinator.observed_revision(); + let newest = host.prepare(&snapshot, newest_revision); + + assert_eq!( + host.publish(stale, newest_revision), + Err(ClientHostError::StaleCandidate { + candidate: stale_revision, + newest: newest_revision, + }) + ); + assert_eq!(host.latest_publication().report.source_revision, 1); + + let publication = host.publish(newest, newest_revision).unwrap(); + assert_eq!(publication.report.source_revision, 2); + } + + #[test] + fn invalid_attempt_updates_editor_but_retains_last_good_play_binding() { + let (_web_root, web) = web_assets(); + let valid = canonical_snapshot(); + let mut coordinator = coordinator(); + let initial_revision = coordinator.observed_revision(); + let (mut host, initial) = ClientHost::activate(web, &valid, initial_revision).unwrap(); + let active = initial.active.expect("valid example has Play artifacts"); + + let invalid_root = TempDirectory::new("invalid-client"); + let invalid = capture_project_snapshot(invalid_root.as_ref()); + coordinator.observe(Observation { + topology: Fingerprint::new("topology-a"), + backend: Fingerprint::new("backend-a"), + client: Some(client_source_fingerprint(&invalid)), + changed_backend_inputs: Vec::new(), + backend_diagnostics: Vec::new(), + }); + let invalid_revision = coordinator.observed_revision(); + let candidate = host.prepare(&invalid, invalid_revision); + let publication = host.publish(candidate, invalid_revision).unwrap(); + + assert!(!publication.report.editor_current); + assert!(!publication.report.play_ok); + assert!(publication.report.has_good_play); + assert_eq!(publication.active.as_ref(), Some(&active)); + assert_eq!(host.active_client(), Some(&active)); + + let response = host.route(RouteRequest { + method: RequestMethod::Get, + url: "/api/play/ir.json", + }); + assert_eq!(response.status, 200); + assert!(matches!(response.body, RouteBody::Bytes(_))); + } +} diff --git a/crates/spock-host/src/events.rs b/crates/spock-host/src/events.rs new file mode 100644 index 0000000..fcf1dec --- /dev/null +++ b/crates/spock-host/src/events.rs @@ -0,0 +1,308 @@ +use std::collections::BTreeMap; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TryRecvError, TrySendError}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::Duration; + +use serde::Serialize; + +pub const PROJECT_EVENT_PROTOCOL: &str = "spock-project-event/1"; +pub const PROJECT_STATUS_PATH: &str = "/~project/status"; +pub const MAX_EVENT_STREAMS_PER_SESSION: usize = 4; + +/// One invalidation for the authoritative project-status snapshot. +/// +/// Events deliberately carry no duplicated status fields. A browser that +/// reconnects, misses an event, or sees an unfamiliar ID always converges by +/// fetching `status_url`. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct ProjectEvent { + pub protocol: &'static str, + pub event_id: u64, + pub status_url: &'static str, +} + +impl ProjectEvent { + fn new(event_id: u64) -> Self { + Self { + protocol: PROJECT_EVENT_PROTOCOL, + event_id, + status_url: PROJECT_STATUS_PATH, + } + } + + fn sse_frame(&self) -> String { + let data = serde_json::to_string(self).expect("project event always serializes"); + format!("id: {}\nevent: invalidate\ndata: {data}\n\n", self.event_id) + } +} + +/// Stable, host-session event hub shared across client publications. +#[derive(Default)] +struct HubState { + current_id: u64, + next_client_id: u64, + clients: BTreeMap>, +} + +pub struct ProjectEventHub { + state: Arc>, + admission: Arc, +} + +impl Default for ProjectEventHub { + fn default() -> Self { + Self::with_admission(Arc::new(EventAdmission::new(MAX_EVENT_STREAMS_PER_SESSION))) + } +} + +pub(crate) struct EventAdmission { + active: Mutex, + limit: usize, +} + +impl EventAdmission { + pub(crate) fn new(limit: usize) -> Self { + Self { + active: Mutex::new(0), + limit, + } + } + + pub(crate) fn try_acquire(self: &Arc) -> Option { + let mut active = self.active.lock().expect("event admission lock"); + if *active >= self.limit { + return None; + } + *active += 1; + Some(EventStreamPermit { + admission: Arc::clone(self), + }) + } + + #[cfg(test)] + fn active(&self) -> usize { + *self.active.lock().expect("event admission lock") + } +} + +pub(crate) struct EventStreamPermit { + admission: Arc, +} + +impl Drop for EventStreamPermit { + fn drop(&mut self) { + let mut active = self.admission.active.lock().expect("event admission lock"); + *active = active + .checked_sub(1) + .expect("event admission count underflow"); + } +} + +impl ProjectEventHub { + pub(crate) fn with_admission(admission: Arc) -> Self { + Self { + state: Arc::new(Mutex::new(HubState::default())), + admission, + } + } + + #[must_use] + pub fn current_id(&self) -> u64 { + self.state + .lock() + .expect("project event hub lock") + .current_id + } + + /// Register a subscriber and immediately send a snapshot invalidation. + /// + /// Registration and snapshot creation share the client lock with + /// publication, so an event may be duplicated at the boundary but cannot + /// be lost. + pub fn subscribe(&self) -> Option { + let admission_permit = self.admission.try_acquire()?; + let (sender, receiver) = mpsc::sync_channel(1); + let client_id = { + let mut state = self.state.lock().expect("project event hub lock"); + let _ = sender.try_send(ProjectEvent::new(state.current_id).sse_frame()); + let client_id = state.next_client_id; + state.next_client_id += 1; + state.clients.insert(client_id, sender); + client_id + }; + Some(ProjectEventStream { + receiver, + client_id, + state: Arc::downgrade(&self.state), + _admission_permit: admission_permit, + }) + } + + /// Advance the session event ID after status and artifacts are visible. + pub fn publish(&self) -> ProjectEvent { + // Allocate and broadcast under one lock so concurrent callers cannot + // deliver a later event ID before an earlier one. + let mut state = self.state.lock().expect("project event hub lock"); + state.current_id += 1; + let event_id = state.current_id; + let event = ProjectEvent::new(event_id); + let frame = event.sse_frame(); + state + .clients + .retain(|_, sender| match sender.try_send(frame.clone()) { + Ok(()) | Err(TrySendError::Full(_)) => true, + Err(TrySendError::Disconnected(_)) => false, + }); + event + } + + #[cfg(test)] + fn subscriber_count(&self) -> usize { + self.state + .lock() + .expect("project event hub lock") + .clients + .len() + } +} + +pub struct ProjectEventStream { + receiver: Receiver, + client_id: u64, + state: Weak>, + _admission_permit: EventStreamPermit, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProjectEventStreamPoll { + Frame(String), + Timeout, + Closed, +} + +impl ProjectEventStream { + /// Poll once without occupying an executor thread. + pub fn try_next_frame(&self) -> ProjectEventStreamPoll { + match self.receiver.try_recv() { + Ok(frame) => ProjectEventStreamPoll::Frame(frame), + Err(TryRecvError::Empty) => ProjectEventStreamPoll::Timeout, + Err(TryRecvError::Disconnected) => ProjectEventStreamPoll::Closed, + } + } + + pub fn next_frame_timeout(&self, timeout: Duration) -> ProjectEventStreamPoll { + match self.receiver.recv_timeout(timeout) { + Ok(frame) => ProjectEventStreamPoll::Frame(frame), + Err(RecvTimeoutError::Timeout) => ProjectEventStreamPoll::Timeout, + Err(RecvTimeoutError::Disconnected) => ProjectEventStreamPoll::Closed, + } + } +} + +impl Drop for ProjectEventStream { + fn drop(&mut self) { + if let Some(state) = self.state.upgrade() { + state + .lock() + .expect("project event hub lock") + .clients + .remove(&self.client_id); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn next(stream: &ProjectEventStream) -> String { + match stream.next_frame_timeout(Duration::from_millis(10)) { + ProjectEventStreamPoll::Frame(frame) => frame, + other => panic!("expected frame, got {other:?}"), + } + } + + fn subscribe(hub: &ProjectEventHub) -> ProjectEventStream { + hub.subscribe().expect("event stream admission") + } + + #[test] + fn a_new_subscriber_immediately_invalidates_to_the_current_snapshot() { + let hub = ProjectEventHub::default(); + hub.publish(); + hub.publish(); + + let frame = next(&subscribe(&hub)); + assert!(frame.starts_with("id: 2\nevent: invalidate\n"), "{frame}"); + assert!(frame.contains(PROJECT_EVENT_PROTOCOL), "{frame}"); + assert!(frame.contains(PROJECT_STATUS_PATH), "{frame}"); + } + + #[test] + fn publications_are_monotonic_and_reach_every_live_subscriber() { + let hub = ProjectEventHub::default(); + let first = subscribe(&hub); + let second = subscribe(&hub); + let _ = next(&first); + let _ = next(&second); + + assert_eq!(hub.publish().event_id, 1); + for stream in [&first, &second] { + assert!(next(stream).starts_with("id: 1\n")); + } + assert_eq!(hub.publish().event_id, 2); + for stream in [&first, &second] { + assert!(next(stream).starts_with("id: 2\n")); + } + } + + #[test] + fn a_dropped_subscriber_does_not_block_later_publication() { + let hub = ProjectEventHub::default(); + drop(subscribe(&hub)); + assert_eq!(hub.subscriber_count(), 0); + assert_eq!(hub.publish().event_id, 1); + assert_eq!(hub.current_id(), 1); + assert_eq!(hub.subscriber_count(), 0); + } + + #[test] + fn a_stalled_subscriber_has_one_bounded_invalidation() { + let hub = ProjectEventHub::default(); + let stream = subscribe(&hub); + for _ in 0..250 { + hub.publish(); + } + + // The connection may see an older invalidation, but that event tells + // it to fetch the authoritative snapshot, now at event ID 250. No + // per-revision queue was accumulated. + let frame = next(&stream); + assert!(frame.starts_with("id: 0\n")); + assert_eq!(hub.current_id(), 250); + assert_eq!( + stream.next_frame_timeout(Duration::from_millis(1)), + ProjectEventStreamPoll::Timeout + ); + } + + #[test] + fn session_admission_is_bounded_and_reusable_after_drop() { + let admission = Arc::new(EventAdmission::new(2)); + let hub = ProjectEventHub::with_admission(Arc::clone(&admission)); + let first = subscribe(&hub); + let second = subscribe(&hub); + assert_eq!(admission.active(), 2); + assert!(hub.subscribe().is_none()); + + drop(first); + assert_eq!(admission.active(), 1); + let replacement = subscribe(&hub); + assert_eq!(admission.active(), 2); + + drop(second); + drop(replacement); + assert_eq!(admission.active(), 0); + assert_eq!(hub.subscriber_count(), 0); + } +} diff --git a/crates/spock-host/src/generation.rs b/crates/spock-host/src/generation.rs new file mode 100644 index 0000000..dbcc7a2 --- /dev/null +++ b/crates/spock-host/src/generation.rs @@ -0,0 +1,762 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub const STATUS_PROTOCOL: &str = "spock-project-status/1"; + +macro_rules! numeric_id { + ($name:ident) => { + #[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] + #[serde(transparent)] + pub struct $name(u64); + + impl $name { + #[must_use] + pub const fn get(self) -> u64 { + self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } + } + }; +} + +numeric_id!(ObservedRevision); +numeric_id!(BackendGenerationId); +numeric_id!(ClientGenerationId); +numeric_id!(ProjectGenerationId); + +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct Fingerprint(String); + +impl Fingerprint { + #[must_use] + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for Fingerprint { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HostMode { + Start, + Dev, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendFreshness { + Active, + RestartRequired, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientFreshness { + Absent, + Building, + Active, + ColdInvalid, + RejectedLastGood, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EditorFreshness { + Current, + Stale, + ColdInvalid, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ClientAttemptState { + Building, + Published, + Rejected, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ClientAttempt { + pub observed_revision: ObservedRevision, + pub state: ClientAttemptState, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Observation { + pub topology: Fingerprint, + pub backend: Fingerprint, + pub client: Option, + pub changed_backend_inputs: Vec, + pub backend_diagnostics: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ObservationDisposition { + NoChange, + Changed { + revision: ObservedRevision, + backend: BackendFreshness, + client_changed: bool, + }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct HealthStatus { + pub ready: bool, + pub degraded: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BackendStatus { + pub generation_id: BackendGenerationId, + pub world_id: String, + pub freshness: BackendFreshness, + pub active_source_fingerprint: Fingerprint, + pub observed_source_fingerprint: Fingerprint, + pub active_topology_fingerprint: Fingerprint, + pub observed_topology_fingerprint: Fingerprint, + pub changed_inputs: Vec, + pub diagnostics: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActiveClientStatus { + pub generation_id: ClientGenerationId, + pub observed_revision: ObservedRevision, + /// Consecutive publication revision owned by the Uhura host. + pub source_revision: u64, + /// Content identity of the exact Uhura source snapshot that produced this + /// publication. This is not a digest of generated browser artifacts. + pub source_fingerprint: Fingerprint, + pub backend_generation_id: BackendGenerationId, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ClientStatus { + pub freshness: ClientFreshness, + pub active: Option, + pub latest_attempt: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActiveProjectStatus { + pub generation_id: ProjectGenerationId, + pub backend_generation_id: BackendGenerationId, + pub client_generation_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ObservedStatus { + pub revision: ObservedRevision, + pub topology_fingerprint: Fingerprint, + pub backend_fingerprint: Fingerprint, + pub client_fingerprint: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProjectStatus { + pub protocol: String, + pub mode: HostMode, + pub observed: ObservedStatus, + pub active_project: ActiveProjectStatus, + pub backend: BackendStatus, + pub client: ClientStatus, + pub editor: EditorFreshness, + pub health: HealthStatus, +} + +#[derive(Debug, Error, Eq, PartialEq)] +pub enum CandidateError { + #[error( + "client candidate revision {candidate} is stale; newest observed revision is {observed}" + )] + Stale { + candidate: ObservedRevision, + observed: ObservedRevision, + }, + #[error("client candidate revision {0} was not started")] + NotStarted(ObservedRevision), +} + +#[derive(Clone, Debug)] +struct ActiveClient { + generation_id: ClientGenerationId, + observed_revision: ObservedRevision, + source_revision: u64, + source_fingerprint: Fingerprint, + backend_generation_id: BackendGenerationId, +} + +/// Pure session state for fixed and watched project generations. +/// +/// There is intentionally no method that replaces the active backend. A +/// process restart constructs another coordinator and another backend world. +#[derive(Clone, Debug)] +pub struct GenerationCoordinator { + mode: HostMode, + observed_revision: ObservedRevision, + active_backend_id: BackendGenerationId, + active_world_id: String, + active_backend: Fingerprint, + active_topology: Fingerprint, + observed_backend: Fingerprint, + observed_topology: Fingerprint, + observed_client: Option, + backend_freshness: BackendFreshness, + changed_backend_inputs: Vec, + observed_backend_diagnostics: Vec, + client_configured: bool, + client_freshness: ClientFreshness, + editor_freshness: EditorFreshness, + latest_attempt: Option, + active_client: Option, + project_generation_id: ProjectGenerationId, + next_client_generation_id: u64, +} + +impl GenerationCoordinator { + #[must_use] + pub fn activated( + mode: HostMode, + backend_fingerprint: Fingerprint, + topology_fingerprint: Fingerprint, + client_fingerprint: Option, + world_id: impl Into, + ) -> Self { + let client_configured = client_fingerprint.is_some(); + Self { + mode, + observed_revision: ObservedRevision(1), + active_backend_id: BackendGenerationId(1), + active_world_id: world_id.into(), + active_backend: backend_fingerprint.clone(), + active_topology: topology_fingerprint.clone(), + observed_backend: backend_fingerprint, + observed_topology: topology_fingerprint, + observed_client: client_fingerprint, + backend_freshness: BackendFreshness::Active, + changed_backend_inputs: Vec::new(), + observed_backend_diagnostics: Vec::new(), + client_configured, + client_freshness: if client_configured { + ClientFreshness::Building + } else { + ClientFreshness::Absent + }, + editor_freshness: if client_configured { + EditorFreshness::ColdInvalid + } else { + EditorFreshness::Current + }, + latest_attempt: None, + active_client: None, + project_generation_id: ProjectGenerationId(1), + next_client_generation_id: 1, + } + } + + #[must_use] + pub const fn observed_revision(&self) -> ObservedRevision { + self.observed_revision + } + + #[must_use] + pub const fn active_backend_generation(&self) -> BackendGenerationId { + self.active_backend_id + } + + /// Record one coherently captured filesystem state. + /// + /// TODO(RFD-0023): replace restart-required with off-path backend candidate + /// construction and an explicit activation policy after development-world + /// semantics are accepted. Never reopen or mutate the active world here. + pub fn observe(&mut self, observation: Observation) -> ObservationDisposition { + if self.observed_topology == observation.topology + && self.observed_backend == observation.backend + && self.observed_client == observation.client + { + return ObservationDisposition::NoChange; + } + + let previous_client = self.observed_client.clone(); + self.observed_revision = ObservedRevision(self.observed_revision.0 + 1); + self.observed_topology = observation.topology; + self.observed_backend = observation.backend; + self.observed_client = observation.client; + self.observed_backend_diagnostics = observation.backend_diagnostics; + + // A client build is eligible only for the exact project observation + // that started it. Terminalize a superseded attempt in the same state + // transition that advances the observation so status can never remain + // `building` after that candidate becomes permanently ineligible. + let superseded_attempt = self.latest_attempt.as_ref().and_then(|attempt| { + (attempt.state == ClientAttemptState::Building).then_some(attempt.observed_revision) + }); + if let Some(revision) = superseded_attempt { + self.finish_client_rejection( + revision, + vec![format!( + "client build for observed revision {revision} was superseded by newer project observation {}", + self.observed_revision + )], + ); + } + + let backend_matches = self.observed_topology == self.active_topology + && self.observed_backend == self.active_backend; + self.backend_freshness = if backend_matches { + self.changed_backend_inputs.clear(); + BackendFreshness::Active + } else { + self.changed_backend_inputs = observation.changed_backend_inputs; + BackendFreshness::RestartRequired + }; + + ObservationDisposition::Changed { + revision: self.observed_revision, + backend: self.backend_freshness, + client_changed: previous_client != self.observed_client, + } + } + + pub fn begin_client_attempt( + &mut self, + revision: ObservedRevision, + ) -> Result<(), CandidateError> { + self.require_newest(revision)?; + self.latest_attempt = Some(ClientAttempt { + observed_revision: revision, + state: ClientAttemptState::Building, + diagnostics: Vec::new(), + }); + self.client_freshness = ClientFreshness::Building; + Ok(()) + } + + pub fn publish_client( + &mut self, + observed_revision: ObservedRevision, + source_revision: u64, + source_fingerprint: Fingerprint, + diagnostics: Vec, + ) -> Result { + self.require_started_newest(observed_revision)?; + let generation_id = ClientGenerationId(self.next_client_generation_id); + self.next_client_generation_id += 1; + self.active_client = Some(ActiveClient { + generation_id, + observed_revision, + source_revision, + source_fingerprint, + backend_generation_id: self.active_backend_id, + }); + self.latest_attempt = Some(ClientAttempt { + observed_revision, + state: ClientAttemptState::Published, + diagnostics, + }); + self.client_freshness = ClientFreshness::Active; + self.editor_freshness = EditorFreshness::Current; + self.project_generation_id = ProjectGenerationId(self.project_generation_id.0 + 1); + Ok(generation_id) + } + + pub fn reject_client( + &mut self, + revision: ObservedRevision, + diagnostics: Vec, + ) -> Result<(), CandidateError> { + self.require_started_newest(revision)?; + self.finish_client_rejection(revision, diagnostics); + Ok(()) + } + + fn finish_client_rejection(&mut self, revision: ObservedRevision, diagnostics: Vec) { + self.latest_attempt = Some(ClientAttempt { + observed_revision: revision, + state: ClientAttemptState::Rejected, + diagnostics, + }); + let has_active_client = self.active_client.is_some(); + self.client_freshness = if has_active_client { + ClientFreshness::RejectedLastGood + } else { + ClientFreshness::ColdInvalid + }; + self.editor_freshness = if has_active_client { + EditorFreshness::Stale + } else { + EditorFreshness::ColdInvalid + }; + } + + #[must_use] + pub fn status(&self) -> ProjectStatus { + let active_client = self + .active_client + .as_ref() + .map(|client| ActiveClientStatus { + generation_id: client.generation_id, + observed_revision: client.observed_revision, + source_revision: client.source_revision, + source_fingerprint: client.source_fingerprint.clone(), + backend_generation_id: client.backend_generation_id, + }); + ProjectStatus { + protocol: STATUS_PROTOCOL.to_owned(), + mode: self.mode, + observed: ObservedStatus { + revision: self.observed_revision, + topology_fingerprint: self.observed_topology.clone(), + backend_fingerprint: self.observed_backend.clone(), + client_fingerprint: self.observed_client.clone(), + }, + active_project: ActiveProjectStatus { + generation_id: self.project_generation_id, + backend_generation_id: self.active_backend_id, + client_generation_id: active_client.as_ref().map(|client| client.generation_id), + }, + backend: BackendStatus { + generation_id: self.active_backend_id, + world_id: self.active_world_id.clone(), + freshness: self.backend_freshness, + active_source_fingerprint: self.active_backend.clone(), + observed_source_fingerprint: self.observed_backend.clone(), + active_topology_fingerprint: self.active_topology.clone(), + observed_topology_fingerprint: self.observed_topology.clone(), + changed_inputs: self.changed_backend_inputs.clone(), + diagnostics: self.observed_backend_diagnostics.clone(), + }, + client: ClientStatus { + freshness: if self.client_configured { + self.client_freshness + } else { + ClientFreshness::Absent + }, + active: active_client, + latest_attempt: self.latest_attempt.clone(), + }, + editor: self.editor_freshness, + health: HealthStatus { + ready: true, + degraded: self.backend_freshness == BackendFreshness::RestartRequired + || matches!( + self.client_freshness, + ClientFreshness::ColdInvalid | ClientFreshness::RejectedLastGood + ), + }, + } + } + + fn require_newest(&self, revision: ObservedRevision) -> Result<(), CandidateError> { + if revision == self.observed_revision { + Ok(()) + } else { + Err(CandidateError::Stale { + candidate: revision, + observed: self.observed_revision, + }) + } + } + + fn require_started_newest(&self, revision: ObservedRevision) -> Result<(), CandidateError> { + self.require_newest(revision)?; + match &self.latest_attempt { + Some(attempt) + if attempt.observed_revision == revision + && attempt.state == ClientAttemptState::Building => + { + Ok(()) + } + _ => Err(CandidateError::NotStarted(revision)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fingerprint(value: &str) -> Fingerprint { + Fingerprint::new(value) + } + + fn coordinator() -> GenerationCoordinator { + GenerationCoordinator::activated( + HostMode::Dev, + fingerprint("backend-a"), + fingerprint("topology-a"), + Some(fingerprint("client-a")), + "world-a", + ) + } + + fn observation(backend: &str, topology: &str, client: &str) -> Observation { + Observation { + topology: fingerprint(topology), + backend: fingerprint(backend), + client: Some(fingerprint(client)), + changed_backend_inputs: vec!["backend/app.spock".to_owned()], + backend_diagnostics: Vec::new(), + } + } + + #[test] + fn identical_observation_is_a_no_op() { + let mut coordinator = coordinator(); + assert_eq!( + coordinator.observe(observation("backend-a", "topology-a", "client-a")), + ObservationDisposition::NoChange + ); + assert_eq!(coordinator.observed_revision(), ObservedRevision(1)); + } + + #[test] + fn backend_change_requires_restart_without_replacing_any_active_identity() { + let mut coordinator = coordinator(); + let backend_id = coordinator.active_backend_generation(); + let project_id = coordinator.status().active_project.generation_id; + + assert_eq!( + coordinator.observe(observation("backend-b", "topology-a", "client-a")), + ObservationDisposition::Changed { + revision: ObservedRevision(2), + backend: BackendFreshness::RestartRequired, + client_changed: false, + } + ); + + let status = coordinator.status(); + assert_eq!(status.backend.generation_id, backend_id); + assert_eq!(status.active_project.generation_id, project_id); + assert_eq!( + status.backend.active_source_fingerprint, + fingerprint("backend-a") + ); + assert_eq!( + status.backend.observed_source_fingerprint, + fingerprint("backend-b") + ); + assert!(status.health.ready); + assert!(status.health.degraded); + } + + #[test] + fn topology_and_backend_must_both_revert_before_restart_required_clears() { + let mut coordinator = coordinator(); + coordinator.observe(observation("backend-b", "topology-b", "client-a")); + coordinator.observe(observation("backend-a", "topology-b", "client-a")); + assert_eq!( + coordinator.status().backend.freshness, + BackendFreshness::RestartRequired + ); + + coordinator.observe(observation("backend-a", "topology-a", "client-a")); + let status = coordinator.status(); + assert_eq!(status.backend.freshness, BackendFreshness::Active); + assert!(status.backend.changed_inputs.is_empty()); + assert_eq!(status.backend.generation_id, BackendGenerationId(1)); + } + + #[test] + fn client_publication_is_bound_to_the_active_backend() { + let mut coordinator = coordinator(); + coordinator + .begin_client_attempt(ObservedRevision(1)) + .unwrap(); + let client_id = coordinator + .publish_client(ObservedRevision(1), 1, fingerprint("source-a"), Vec::new()) + .unwrap(); + let status = coordinator.status(); + assert_eq!(status.client.freshness, ClientFreshness::Active); + assert_eq!( + status.client.active.as_ref().unwrap().generation_id, + client_id + ); + assert_eq!( + status.client.active.as_ref().unwrap().backend_generation_id, + BackendGenerationId(1) + ); + assert_eq!( + status.client.active.as_ref().unwrap().source_fingerprint, + fingerprint("source-a") + ); + assert_eq!(status.active_project.client_generation_id, Some(client_id)); + let value = serde_json::to_value(&status).unwrap(); + assert_eq!(value["client"]["active"]["source_fingerprint"], "source-a"); + assert!(value["client"]["active"] + .get("artifact_fingerprint") + .is_none()); + } + + #[test] + fn initial_client_rejection_is_cold_without_claiming_a_last_good_generation() { + let mut coordinator = coordinator(); + coordinator + .begin_client_attempt(ObservedRevision(1)) + .unwrap(); + coordinator + .reject_client(ObservedRevision(1), vec!["UH0001".to_owned()]) + .unwrap(); + + let status = coordinator.status(); + assert_eq!(status.client.freshness, ClientFreshness::ColdInvalid); + assert!(status.client.active.is_none()); + assert_eq!( + status.client.latest_attempt, + Some(ClientAttempt { + observed_revision: ObservedRevision(1), + state: ClientAttemptState::Rejected, + diagnostics: vec!["UH0001".to_owned()], + }) + ); + assert_eq!(status.editor, EditorFreshness::ColdInvalid); + assert!(status.health.ready); + assert!(status.health.degraded); + + let value = serde_json::to_value(status).unwrap(); + assert_eq!(value["client"]["freshness"], "cold_invalid"); + assert!(value["client"]["active"].is_null()); + } + + #[test] + fn invalid_client_retains_last_good_and_keeps_attempt_identity_separate() { + let mut coordinator = coordinator(); + coordinator + .begin_client_attempt(ObservedRevision(1)) + .unwrap(); + let good = coordinator + .publish_client(ObservedRevision(1), 1, fingerprint("source-a"), Vec::new()) + .unwrap(); + + coordinator.observe(observation("backend-a", "topology-a", "client-b")); + coordinator + .begin_client_attempt(ObservedRevision(2)) + .unwrap(); + coordinator + .reject_client(ObservedRevision(2), vec!["UH0001".to_owned()]) + .unwrap(); + + let status = coordinator.status(); + assert_eq!(status.client.freshness, ClientFreshness::RejectedLastGood); + assert_eq!(status.client.active.unwrap().generation_id, good); + assert_eq!( + status.client.latest_attempt, + Some(ClientAttempt { + observed_revision: ObservedRevision(2), + state: ClientAttemptState::Rejected, + diagnostics: vec!["UH0001".to_owned()], + }) + ); + assert_eq!(status.editor, EditorFreshness::Stale); + } + + #[test] + fn a_newer_observation_permanently_makes_an_older_candidate_ineligible() { + let mut coordinator = coordinator(); + coordinator + .begin_client_attempt(ObservedRevision(1)) + .unwrap(); + coordinator.observe(observation("backend-a", "topology-a", "client-b")); + assert_eq!( + coordinator.publish_client( + ObservedRevision(1), + 1, + fingerprint("source-old"), + Vec::new(), + ), + Err(CandidateError::Stale { + candidate: ObservedRevision(1), + observed: ObservedRevision(2), + }) + ); + let status = coordinator.status(); + assert_eq!(status.client.freshness, ClientFreshness::ColdInvalid); + assert!(status.client.active.is_none()); + let attempt = status.client.latest_attempt.expect("terminal attempt"); + assert_eq!(attempt.observed_revision, ObservedRevision(1)); + assert_eq!(attempt.state, ClientAttemptState::Rejected); + assert!(attempt + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("superseded by newer project observation 2"))); + } + + #[test] + fn client_can_publish_while_backend_restart_is_required() { + let mut coordinator = coordinator(); + coordinator.observe(observation("backend-b", "topology-a", "client-b")); + coordinator + .begin_client_attempt(ObservedRevision(2)) + .unwrap(); + coordinator + .publish_client(ObservedRevision(2), 2, fingerprint("source-b"), Vec::new()) + .unwrap(); + + let status = coordinator.status(); + assert_eq!(status.backend.freshness, BackendFreshness::RestartRequired); + assert_eq!(status.client.freshness, ClientFreshness::Active); + assert_eq!( + status.client.active.unwrap().backend_generation_id, + BackendGenerationId(1) + ); + } + + #[test] + fn status_protocol_and_attempted_generation_are_serialized() { + let mut coordinator = coordinator(); + coordinator + .begin_client_attempt(ObservedRevision(1)) + .unwrap(); + let value = serde_json::to_value(coordinator.status()).unwrap(); + assert_eq!(value["protocol"], STATUS_PROTOCOL); + assert_eq!(value["client"]["freshness"], "building"); + assert_eq!(value["client"]["latest_attempt"]["state"], "building"); + assert_eq!(value["backend"]["generation_id"], 1); + } + + #[test] + fn successful_client_publication_preserves_warnings() { + let mut coordinator = coordinator(); + coordinator + .begin_client_attempt(ObservedRevision(1)) + .unwrap(); + coordinator + .publish_client( + ObservedRevision(1), + 1, + fingerprint("source-a"), + vec!["UH1000: warning".to_owned()], + ) + .unwrap(); + + assert_eq!( + coordinator + .status() + .client + .latest_attempt + .expect("published attempt") + .diagnostics, + vec!["UH1000: warning"] + ); + } +} diff --git a/crates/spock-host/src/http.rs b/crates/spock-host/src/http.rs new file mode 100644 index 0000000..631bbcb --- /dev/null +++ b/crates/spock-host/src/http.rs @@ -0,0 +1,798 @@ +use std::convert::Infallible; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use axum::body::{Body, Bytes}; +use axum::extract::Request; +use axum::http::header::{CACHE_CONTROL, CONTENT_TYPE, RETRY_AFTER}; +use axum::http::{HeaderName, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::get; +use axum::{Json, Router}; +use serde::Serialize; +use serde_json::json; +use spock_runtime::error::ApiError; +use spock_runtime::generation::BackendGeneration; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TrySendError; +use tokio_stream::wrappers::ReceiverStream; +use uhura_host::{EventStreamPoll, RequestMethod, RouteBody, RouteRequest, RouteResponse}; + +use crate::events::{EventAdmission, EventStreamPermit, MAX_EVENT_STREAMS_PER_SESSION}; +use crate::{ + classify_route, BackendGenerationId, ClientHost, GenerationCoordinator, HostMode, + ProjectEventHub, ProjectEventStreamPoll, ProjectGenerationId, ProjectStatus, RouteOwner, +}; + +pub const HOST_ENVIRONMENT_PROTOCOL: &str = "spock-host-environment/1"; +const STREAM_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// One active backend, optional client publication service, and stable +/// host-session status/event state. +/// +/// The backend generation is immutable. Development observation can mutate +/// only the coordinator and client publication behind their narrow locks. +pub struct FrameworkSession { + backend: Arc, + publication: Arc>, + events: Arc, + stream_admission: Arc, + stream_shutdown: Arc, +} + +pub(crate) struct PublicationState { + pub coordinator: GenerationCoordinator, + pub client: Option, +} + +impl FrameworkSession { + #[must_use] + pub(crate) fn new( + backend: BackendGeneration, + client: Option, + coordinator: GenerationCoordinator, + ) -> Self { + let stream_admission = Arc::new(EventAdmission::new(MAX_EVENT_STREAMS_PER_SESSION)); + Self { + backend: Arc::new(backend), + publication: Arc::new(RwLock::new(PublicationState { + coordinator, + client, + })), + events: Arc::new(ProjectEventHub::with_admission(Arc::clone( + &stream_admission, + ))), + stream_admission, + stream_shutdown: Arc::new(AtomicBool::new(false)), + } + } + + #[must_use] + pub fn backend(&self) -> Arc { + Arc::clone(&self.backend) + } + + #[must_use] + pub(crate) fn publication(&self) -> Arc> { + Arc::clone(&self.publication) + } + + #[must_use] + pub(crate) fn events(&self) -> Arc { + Arc::clone(&self.events) + } + + pub(crate) fn shutdown_streams(&self) { + self.stream_shutdown.store(true, Ordering::Release); + } + + #[must_use] + pub fn status(&self) -> ProjectStatus { + self.publication + .read() + .expect("project publication lock") + .coordinator + .status() + } + + /// Build the one-origin router without binding a listener. + pub fn router(&self) -> Result { + let authority = self.backend.authority_router()?; + let graphql_available = + !self.backend.contract().tables.is_empty() || !self.backend.contract().fns.is_empty(); + + let status_state = self.publication(); + let environment_state = self.publication(); + let health_state = self.publication(); + let project_events = self.events(); + let publication = self.publication(); + let client_configured = publication + .read() + .expect("project publication lock") + .client + .is_some(); + let event_shutdown = Arc::clone(&self.stream_shutdown); + let client_shutdown = Arc::clone(&self.stream_shutdown); + let client_stream_admission = Arc::clone(&self.stream_admission); + + let framework = Router::new() + .route( + "/~project/status", + get(move || async move { + ( + [(CACHE_CONTROL, HeaderValue::from_static("no-store"))], + Json( + status_state + .read() + .expect("project publication lock") + .coordinator + .status(), + ), + ) + }), + ) + .route( + "/~project/environment", + get(move || async move { + let status = environment_state + .read() + .expect("project publication lock") + .coordinator + .status(); + ( + [(CACHE_CONTROL, HeaderValue::from_static("no-store"))], + Json(HostEnvironment::from_status(&status, graphql_available)), + ) + }), + ) + .route( + "/~project/events", + get(move || { + let stream = project_events.subscribe(); + let shutdown = Arc::clone(&event_shutdown); + async move { + match stream { + Some(stream) => project_event_response(stream, shutdown), + None => event_capacity_error(), + } + } + }) + .head(|| async { event_method_error() }), + ) + .route( + "/~health", + get(move || async move { + let status = health_state + .read() + .expect("project publication lock") + .coordinator + .status(); + let code = if status.health.ready { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + ( + code, + [(CACHE_CONTROL, HeaderValue::from_static("no-store"))], + Json(json!({ + "ok": status.health.ready, + "ready": status.health.ready, + "degraded": status.health.degraded, + })), + ) + }), + ) + .fallback(move |request: Request| { + let publication = Arc::clone(&publication); + let shutdown = Arc::clone(&client_shutdown); + let stream_admission = Arc::clone(&client_stream_admission); + async move { + combined_fallback( + request, + publication, + client_configured, + shutdown, + stream_admission, + ) + .await + } + }); + + Ok(authority.merge(framework)) + } +} + +#[derive(Serialize)] +struct HostEnvironment { + protocol: &'static str, + mode: HostMode, + project_generation_id: ProjectGenerationId, + backend_generation_id: BackendGenerationId, + authority: AuthorityEnvironment, +} + +#[derive(Serialize)] +struct AuthorityEnvironment { + graphql_path: Option<&'static str>, + rpc_path: &'static str, + storage_path: &'static str, +} + +impl HostEnvironment { + fn from_status(status: &ProjectStatus, graphql_available: bool) -> Self { + Self { + protocol: HOST_ENVIRONMENT_PROTOCOL, + mode: status.mode, + project_generation_id: status.active_project.generation_id, + backend_generation_id: status.active_project.backend_generation_id, + authority: AuthorityEnvironment { + graphql_path: graphql_available.then_some("/graphql/v1"), + rpc_path: "/rest/v1/rpc", + storage_path: "/storage/v1", + }, + } + } +} + +async fn combined_fallback( + request: Request, + publication: Arc>, + client_configured: bool, + stream_shutdown: Arc, + stream_admission: Arc, +) -> Response { + let path = request.uri().path().to_string(); + match classify_route(&path, client_configured) { + RouteOwner::Client => { + route_client(request, &publication, stream_shutdown, stream_admission) + } + RouteOwner::Framework if path == "/" && !client_configured => { + if matches!( + *request.method(), + axum::http::Method::GET | axum::http::Method::HEAD + ) { + Redirect::temporary("/~studio").into_response() + } else { + let mut response = ( + StatusCode::METHOD_NOT_ALLOWED, + Json(json!({ + "error": { + "code": "bad_request", + "kind": "bad_request", + "table": null, + "fields": [], + "message": "the backend-only project root accepts GET and HEAD", + } + })), + ) + .into_response(); + response + .headers_mut() + .insert("allow", HeaderValue::from_static("GET, HEAD")); + response + } + } + RouteOwner::Framework + | RouteOwner::Authority + | RouteOwner::ProtocolNotFound + | RouteOwner::NotFound => protocol_not_found(&path), + } +} + +fn protocol_not_found(path: &str) -> Response { + ApiError::not_found(format!("no such path: {path}")).into_response() +} + +fn route_client( + request: Request, + publication: &RwLock, + stream_shutdown: Arc, + stream_admission: Arc, +) -> Response { + let method = match *request.method() { + axum::http::Method::GET => RequestMethod::Get, + axum::http::Method::HEAD => RequestMethod::Head, + _ => RequestMethod::Other, + }; + let url = request + .uri() + .path_and_query() + .map_or_else(|| request.uri().path(), |value| value.as_str()); + let publication = publication.read().expect("project publication lock"); + let Some(client) = &publication.client else { + return protocol_not_found(request.uri().path()); + }; + let response = client.route(RouteRequest { method, url }); + drop(publication); + uhura_response(response, stream_shutdown, stream_admission) +} + +fn uhura_response( + response: RouteResponse, + stream_shutdown: Arc, + stream_admission: Arc, +) -> Response { + let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut builder = Response::builder().status(status); + for (name, value) in response.headers { + let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else { + return internal_transport_error("Uhura returned an invalid response header name"); + }; + let Ok(value) = HeaderValue::from_str(&value) else { + return internal_transport_error("Uhura returned an invalid response header value"); + }; + builder = builder.header(name, value); + } + + let body = match response.body { + RouteBody::Bytes(bytes) => Body::from(bytes.into_inner()), + RouteBody::Events(stream) => { + let Some(admission_permit) = stream_admission.try_acquire() else { + return event_capacity_error(); + }; + let (sender, receiver) = mpsc::channel::>(1); + tokio::spawn(async move { + let _admission_permit: EventStreamPermit = admission_permit; + loop { + if sender.is_closed() || stream_shutdown.load(Ordering::Acquire) { + break; + } + match stream.try_next_frame() { + EventStreamPoll::Frame(frame) => { + match sender.try_send(Ok(Bytes::from(frame))) { + Ok(()) | Err(TrySendError::Full(_)) => {} + Err(TrySendError::Closed(_)) => break, + } + } + EventStreamPoll::Timeout => { + tokio::time::sleep(STREAM_POLL_INTERVAL).await; + } + EventStreamPoll::Closed => break, + } + } + }); + Body::from_stream(ReceiverStream::new(receiver)) + } + }; + builder + .body(body) + .unwrap_or_else(|_| internal_transport_error("could not build the Uhura response")) +} + +fn project_event_response( + stream: crate::ProjectEventStream, + stream_shutdown: Arc, +) -> Response { + let (sender, receiver) = mpsc::channel::>(1); + tokio::spawn(async move { + loop { + if sender.is_closed() || stream_shutdown.load(Ordering::Acquire) { + break; + } + match stream.try_next_frame() { + ProjectEventStreamPoll::Frame(frame) => { + match sender.try_send(Ok(Bytes::from(frame))) { + Ok(()) | Err(TrySendError::Full(_)) => {} + Err(TrySendError::Closed(_)) => break, + } + } + ProjectEventStreamPoll::Timeout => { + tokio::time::sleep(STREAM_POLL_INTERVAL).await; + } + ProjectEventStreamPoll::Closed => break, + } + } + }); + + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "text/event-stream; charset=utf-8") + .header(CACHE_CONTROL, "no-store") + .body(Body::from_stream(ReceiverStream::new(receiver))) + .unwrap_or_else(|_| internal_transport_error("could not build the project event response")) +} + +fn event_method_error() -> Response { + let mut response = ( + StatusCode::METHOD_NOT_ALLOWED, + Json(json!({ + "error": { + "code": "bad_request", + "kind": "bad_request", + "table": null, + "fields": [], + "message": "/~project/events requires GET", + } + })), + ) + .into_response(); + response + .headers_mut() + .insert("allow", HeaderValue::from_static("GET")); + response +} + +fn event_capacity_error() -> Response { + let mut response = ( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "error": { + "code": "unavailable", + "kind": "unavailable", + "table": null, + "fields": [], + "message": "too many active framework event streams; retry shortly", + } + })), + ) + .into_response(); + response + .headers_mut() + .insert(RETRY_AFTER, HeaderValue::from_static("1")); + response + .headers_mut() + .insert(CACHE_CONTROL, HeaderValue::from_static("no-store")); + response +} + +fn internal_transport_error(message: &'static str) -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "error": { + "code": "internal", + "kind": "internal", + "table": null, + "fields": [], + "message": message, + } + })), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::fs; + + use axum::body::to_bytes; + use axum::http::Request; + use spock_project::minimal_uhura_client_template; + use spock_runtime::generation::CapturedBackend; + use tempfile::tempdir; + use tower::ServiceExt; + use uhura_host::{capture_project_snapshot, WebAssets}; + + use super::*; + use crate::{client_source_fingerprint, ClientHost, Fingerprint, GenerationCoordinator}; + + fn backend_only() -> FrameworkSession { + session_with_source("") + } + + fn session_with_source(source: &str) -> FrameworkSession { + let backend = + BackendGeneration::from_captured(CapturedBackend::new(source, BTreeMap::new()), None) + .expect("backend generation"); + let coordinator = GenerationCoordinator::activated( + HostMode::Start, + Fingerprint::new("backend"), + Fingerprint::new("topology"), + None, + "world-1", + ); + FrameworkSession::new(backend, None, coordinator) + } + + fn client_session() -> FrameworkSession { + let project = tempdir().expect("temporary client project"); + for file in minimal_uhura_client_template().files() { + let path = project.path().join(file.path().as_path()); + fs::create_dir_all(path.parent().expect("client file parent")).unwrap(); + fs::write(path, file.contents()).unwrap(); + } + let snapshot = capture_project_snapshot(project.path()); + + let web_root = tempdir().expect("temporary web assets"); + fs::create_dir_all(web_root.path().join("assets")).unwrap(); + fs::write( + web_root.path().join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(web_root.path().join("assets/app.js"), "export {};\n").unwrap(); + let web = WebAssets::from_frontend_directory(web_root.path()).unwrap(); + + let backend = + BackendGeneration::from_captured(CapturedBackend::new("", BTreeMap::new()), None) + .expect("backend generation"); + let source_fingerprint = client_source_fingerprint(&snapshot); + let mut coordinator = GenerationCoordinator::activated( + HostMode::Start, + Fingerprint::new("backend"), + Fingerprint::new("topology"), + Some(source_fingerprint.clone()), + "world-1", + ); + let revision = coordinator.observed_revision(); + coordinator.begin_client_attempt(revision).unwrap(); + let (client, publication) = ClientHost::activate(web, &snapshot, revision).unwrap(); + assert!(publication.report.editor_current); + assert!(publication.report.play_ok); + coordinator + .publish_client( + revision, + publication.report.source_revision, + source_fingerprint, + Vec::new(), + ) + .unwrap(); + FrameworkSession::new(backend, Some(client), coordinator) + } + + async fn response_json(response: Response) -> serde_json::Value { + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body bytes"); + serde_json::from_slice(&bytes).expect("JSON body") + } + + #[tokio::test] + async fn backend_only_root_redirects_and_unknown_protocol_paths_are_json() { + let router = backend_only().router().expect("router"); + let root = router + .clone() + .oneshot(Request::get("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(root.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!(root.headers()["location"], "/~studio"); + + let unknown = router + .clone() + .oneshot( + Request::get("/api/not-a-client-route") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(unknown.status(), StatusCode::NOT_FOUND); + assert_eq!(response_json(unknown).await["error"]["code"], "not_found"); + + let post_root = router + .clone() + .oneshot(Request::post("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(post_root.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(post_root.headers()["allow"], "GET, HEAD"); + + let event_head = router + .oneshot( + Request::head("/~project/events") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(event_head.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(event_head.headers()["allow"], "GET"); + } + + #[tokio::test] + async fn encoded_client_protocol_spelling_cannot_fall_through_to_spa_html() { + let session = backend_only(); + let response = combined_fallback( + Request::get("/api%2Feditor/state") + .body(Body::empty()) + .unwrap(), + session.publication(), + true, + Arc::new(AtomicBool::new(false)), + Arc::new(EventAdmission::new(MAX_EVENT_STREAMS_PER_SESSION)), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.headers()[CONTENT_TYPE], "application/json"); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(!bytes + .windows(b"(&bytes).unwrap()["error"]["code"], + "not_found" + ); + } + + #[tokio::test] + async fn fixed_status_environment_health_and_empty_contract_share_one_router() { + let router = backend_only().router().expect("router"); + for path in [ + "/~project/status", + "/~project/environment", + "/~health", + "/~contract", + ] { + let response = router + .clone() + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + if path != "/~contract" { + assert_eq!(response.headers()[CACHE_CONTROL], "no-store", "{path}"); + } + } + + let environment = router + .clone() + .oneshot( + Request::get("/~project/environment") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let environment = response_json(environment).await; + assert_eq!(environment["protocol"], HOST_ENVIRONMENT_PROTOCOL); + assert_eq!(environment["mode"], "start"); + assert!(environment["authority"]["graphql_path"].is_null()); + + let graphql = router + .oneshot(Request::get("/graphql/v1").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(graphql.status(), StatusCode::NOT_FOUND); + assert_eq!(response_json(graphql).await["error"]["kind"], "not_found"); + } + + #[tokio::test] + async fn a_non_empty_contract_advertises_graphql() { + let router = session_with_source("table note { key id: uuid = auto }\n") + .router() + .expect("router"); + let environment = router + .oneshot( + Request::get("/~project/environment") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response_json(environment).await["authority"]["graphql_path"], + "/graphql/v1" + ); + } + + #[tokio::test] + async fn combined_host_grants_no_cross_origin_access() { + let router = backend_only().router().expect("router"); + for path in ["/~project/status", "/~contract"] { + let response = router + .clone() + .oneshot( + Request::get(path) + .header("origin", "https://arbitrary.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{path}"); + assert!( + !response + .headers() + .contains_key("access-control-allow-origin"), + "{path} must remain same-origin" + ); + } + + let preflight = router + .oneshot( + Request::builder() + .method("OPTIONS") + .uri("/rest/v1/rpc/example") + .header("origin", "https://arbitrary.example") + .header("access-control-request-method", "POST") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(!preflight + .headers() + .contains_key("access-control-allow-origin")); + } + + #[tokio::test] + async fn project_and_uhura_event_routes_share_one_reusable_session_budget() { + let router = client_session().router().expect("router"); + let mut streams = Vec::new(); + for path in [ + "/~project/events", + "/api/editor/events", + "/~project/events", + "/api/play/events", + ] { + let response = router + .clone() + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "GET {path}"); + streams.push(response); + } + + for path in ["/~project/events", "/api/editor/events"] { + let saturated = router + .clone() + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!( + saturated.status(), + StatusCode::SERVICE_UNAVAILABLE, + "GET {path}" + ); + assert_eq!(saturated.headers()[RETRY_AFTER], "1"); + assert_eq!( + response_json(saturated).await["error"]["code"], + "unavailable" + ); + } + + drop(streams.pop().expect("one admitted stream")); + let replacement = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let response = router + .clone() + .oneshot( + Request::get("/api/editor/events") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + if response.status() == StatusCode::OK { + break response; + } + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + tokio::time::sleep(STREAM_POLL_INTERVAL).await; + } + }) + .await + .expect("dropped event response should release admission"); + streams.push(replacement); + assert_eq!(streams.len(), MAX_EVENT_STREAMS_PER_SESSION); + + drop(streams.remove(0)); + let replacement = tokio::time::timeout(Duration::from_secs(1), async { + loop { + let response = router + .clone() + .oneshot( + Request::get("/api/play/events") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + if response.status() == StatusCode::OK { + break response; + } + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + tokio::time::sleep(STREAM_POLL_INTERVAL).await; + } + }) + .await + .expect("dropped project event response should release admission"); + streams.push(replacement); + assert_eq!(streams.len(), MAX_EVENT_STREAMS_PER_SESSION); + } +} diff --git a/crates/spock-host/src/lib.rs b/crates/spock-host/src/lib.rs new file mode 100644 index 0000000..c191d2c --- /dev/null +++ b/crates/spock-host/src/lib.rs @@ -0,0 +1,49 @@ +//! Coordination and hosting for one Spock framework project. +//! +//! The pure generation state machine lives independently from filesystem and +//! HTTP adapters so its activation laws can be exhaustively tested. Listener, +//! observer, and subsystem adapters are layered on this crate. + +mod assets; +mod backend_capture; +mod client; +mod events; +mod generation; +mod http; +mod named_state; +mod project; +mod routing; +mod server; + +pub use assets::{ + load_uhura_assets, AssetError, UhuraAssetRoots, SPOCK_UHURA_WASM_DIST, SPOCK_UHURA_WEB_DIST, +}; +pub use backend_capture::{ + capture_backend, observe_backend, BackendDiagnostic, BackendDiagnosticCode, BackendDiagnostics, + BackendObservation, +}; +pub use client::{ + client_source_fingerprint, ActiveClientBinding, ClientHost, ClientHostError, ClientPublication, + PreparedClient, +}; +pub use events::{ + ProjectEvent, ProjectEventHub, ProjectEventStream, ProjectEventStreamPoll, + PROJECT_EVENT_PROTOCOL, PROJECT_STATUS_PATH, +}; +pub use generation::{ + BackendFreshness, BackendGenerationId, CandidateError, ClientAttempt, ClientAttemptState, + ClientFreshness, ClientGenerationId, EditorFreshness, Fingerprint, GenerationCoordinator, + HealthStatus, HostMode, Observation, ObservationDisposition, ObservedRevision, + ProjectGenerationId, ProjectStatus, +}; +pub use http::{FrameworkSession, HOST_ENVIRONMENT_PROTOCOL}; +pub use named_state::{named_state_lock_path, NamedStateLock, NamedStateLockError}; +pub use project::{ + check_project, topology_fingerprint, BackendCheckSummary, ClientCheckSummary, HostError, + ProjectCheckDiagnostic, ProjectCheckFailure, ProjectCheckReport, ProjectComponent, + ProjectDiagnosticPosition, ProjectDiagnosticSpan, +}; +pub use routing::{classify_route, RouteOwner}; +pub use server::{ + serve_project, HostNotice, HostNoticeSink, ServeError, ServeOptions, ServeOutcome, +}; diff --git a/crates/spock-host/src/named_state.rs b/crates/spock-host/src/named_state.rs new file mode 100644 index 0000000..ed9a85d --- /dev/null +++ b/crates/spock-host/src/named_state.rs @@ -0,0 +1,711 @@ +//! Process ownership for an explicitly named database world. +//! +//! The lock file is only the stable object on which the operating system holds +//! an advisory lock. Its contents and existence carry no ownership meaning: +//! there is no PID record to inspect and no stale sentinel to delete. + +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions, TryLockError}; +use std::path::{Component, Path, PathBuf}; + +use sha2::{Digest, Sha256}; +use thiserror::Error; +#[cfg(any(windows, target_os = "macos"))] +use unicode_normalization::UnicodeNormalization; + +const LOCK_DIRECTORY: &str = ".spock-named-state-locks"; +const LOCK_SUFFIX: &str = ".lock"; + +/// Derive the advisory-lock path for one named `--db` value. +/// +/// The database is first reduced to a normalized absolute filesystem identity. +/// Its lock then lives in a reserved sibling directory under a SHA-256 name. +/// Database, WAL, SHM, and historical `*.spock.lock` paths therefore cannot +/// accidentally be the advisory object itself. +#[must_use] +pub fn named_state_lock_path(database_path: &Path) -> PathBuf { + let identity = resolved_database_entry(database_path).unwrap_or_else(|_| { + lexically_absolute(database_path).unwrap_or_else(|_| database_path.into()) + }); + lock_path_for_identity(&identity) +} + +/// Failures before a host owns its named database world. +#[derive(Debug, Error)] +pub enum NamedStateLockError { + #[error("could not resolve named database identity {}: {source}", path.display())] + ResolveDatabase { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error( + "named database {} is inside reserved lock namespace `{LOCK_DIRECTORY}`", + path.display() + )] + ReservedDatabasePath { path: PathBuf }, + #[error( + "named database {} has {links} hard links; use a database path with one directory entry", + path.display() + )] + HardLinkedDatabase { path: PathBuf, links: u64 }, + #[error("could not create named-state lock directory {}: {source}", path.display())] + CreateParent { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("could not open named-state lock {}: {source}", path.display())] + Open { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error( + "named database {} is already owned by another process (lock {})", + database_path.display(), + lock_path.display() + )] + Contended { + database_path: PathBuf, + lock_path: PathBuf, + }, + #[error("could not acquire named-state lock {}: {source}", path.display())] + Acquire { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +/// Exclusive process-lifetime ownership of one named database world. +/// +/// Acquire this before creating, deleting, or opening the database, its WAL or +/// SHM files, or any related mutable framework state. Keep the value in the +/// host owner for at least as long as those resources are live. +#[derive(Debug)] +pub struct NamedStateLock { + database_path: PathBuf, + resolved_database_path: PathBuf, + lock_path: PathBuf, + file: File, +} + +impl NamedStateLock { + /// Acquire an exclusive lock without waiting. + /// + /// This creates missing parent directories for the lock but deliberately + /// does not touch the database path. A competing owner produces + /// [`NamedStateLockError::Contended`] immediately. + pub fn acquire(database_path: impl AsRef) -> Result { + let database_path = database_path.as_ref().to_path_buf(); + let resolved_database_path = resolved_database_entry(&database_path).map_err(|source| { + NamedStateLockError::ResolveDatabase { + path: database_path.clone(), + source, + } + })?; + if database_uses_lock_namespace(&resolved_database_path) { + return Err(NamedStateLockError::ReservedDatabasePath { + path: database_path, + }); + } + let link_count = + existing_regular_file_link_count(&resolved_database_path).map_err(|source| { + NamedStateLockError::ResolveDatabase { + path: database_path.clone(), + source, + } + })?; + if let Some(links) = link_count.filter(|links| *links > 1) { + return Err(NamedStateLockError::HardLinkedDatabase { + path: database_path, + links, + }); + } + let lock_path = lock_path_for_identity(&resolved_database_path); + + if let Some(parent) = lock_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent).map_err(|source| { + NamedStateLockError::CreateParent { + path: parent.to_path_buf(), + source, + } + })?; + } + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|source| NamedStateLockError::Open { + path: lock_path.clone(), + source, + })?; + + match file.try_lock() { + Ok(()) => Ok(Self { + database_path, + resolved_database_path, + lock_path, + file, + }), + Err(TryLockError::WouldBlock) => Err(NamedStateLockError::Contended { + database_path, + lock_path, + }), + Err(TryLockError::Error(source)) => Err(NamedStateLockError::Acquire { + path: lock_path, + source, + }), + } + } + + #[must_use] + pub fn database_path(&self) -> &Path { + &self.database_path + } + + /// The stable directory-entry path protected by this lock. + /// + /// Destructive database bootstrap must use this path rather than the + /// caller's spelling. Its parent has been resolved without following the + /// final database component, so a final-component symlink cannot change + /// the lock identity when bootstrap replaces it. + #[must_use] + pub fn resolved_database_path(&self) -> &Path { + &self.resolved_database_path + } + + #[must_use] + pub fn lock_path(&self) -> &Path { + &self.lock_path + } +} + +fn resolved_database_entry(database_path: &Path) -> std::io::Result { + let absolute = lexically_absolute(database_path)?; + let file_name = absolute.file_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "database path must name a directory entry", + ) + })?; + let parent = absolute.parent().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "database path must have a parent directory", + ) + })?; + resolve_directory(parent).map(|resolved_parent| resolved_parent.join(file_name)) +} + +fn resolve_directory(directory: &Path) -> std::io::Result { + let mut cursor = directory.to_path_buf(); + let mut missing = Vec::::new(); + + loop { + match std::fs::symlink_metadata(&cursor) { + Ok(_) => { + let mut resolved = std::fs::canonicalize(&cursor)?; + for component in missing.iter().rev() { + resolved.push(component); + } + return Ok(resolved); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let name = cursor.file_name().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "database path has no existing ancestor", + ) + })?; + missing.push(name.to_os_string()); + cursor = cursor + .parent() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "database path has no existing ancestor", + ) + })? + .to_path_buf(); + } + Err(error) => return Err(error), + } + } +} + +fn existing_regular_file_link_count(path: &Path) -> std::io::Result> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error), + }; + if !metadata.file_type().is_file() { + // Destructive bootstrap intentionally acts on a final-component + // symlink as a directory entry rather than following it. Preserve that + // behavior; only existing regular database files have hard-link aliases. + return Ok(None); + } + metadata_link_count(path, &metadata) +} + +#[cfg(unix)] +fn metadata_link_count(_path: &Path, metadata: &std::fs::Metadata) -> std::io::Result> { + use std::os::unix::fs::MetadataExt; + + Ok(Some(metadata.nlink())) +} + +#[cfg(windows)] +fn metadata_link_count(path: &Path, _metadata: &std::fs::Metadata) -> std::io::Result> { + use cap_fs_ext::MetadataExt as _; + use std::os::windows::fs::OpenOptionsExt as _; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, + }; + + // Stable Rust does not expose a Windows hard-link count on path metadata. + // Query capability metadata derived from a handle instead. Opening the + // final entry as a reparse point also prevents a raced symlink from being + // followed while obtaining that handle. + let file = std::fs::OpenOptions::new() + .access_mode(FILE_READ_ATTRIBUTES) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path)?; + let metadata = cap_std::fs::File::from_std(file).metadata()?; + if !metadata.is_file() { + return Ok(None); + } + + Ok(Some(metadata.nlink())) +} + +#[cfg(not(any(unix, windows)))] +fn metadata_link_count( + _path: &Path, + _metadata: &std::fs::Metadata, +) -> std::io::Result> { + // The supported release targets expose link counts. Other targets retain + // path ownership rather than guessing at unavailable file identity. + Ok(None) +} + +fn lexically_absolute(path: &Path) -> std::io::Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }; + let mut normalized = PathBuf::new(); + for component in absolute.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(segment) => normalized.push(segment), + } + } + Ok(normalized) +} + +fn lock_path_for_identity(identity: &Path) -> PathBuf { + let parent = identity + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let mut name = hex_digest(&database_identity_digest(identity)); + name.push_str(LOCK_SUFFIX); + parent.join(LOCK_DIRECTORY).join(name) +} + +fn database_uses_lock_namespace(identity: &Path) -> bool { + identity.components().any(|component| match component { + Component::Normal(segment) => lock_directory_name_matches(segment), + _ => false, + }) +} + +#[cfg(any(windows, target_os = "macos"))] +fn lock_directory_name_matches(segment: &OsStr) -> bool { + segment + .to_str() + .is_some_and(|segment| segment.eq_ignore_ascii_case(LOCK_DIRECTORY)) +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn lock_directory_name_matches(segment: &OsStr) -> bool { + segment == LOCK_DIRECTORY +} + +fn database_identity_digest(identity: &Path) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(b"spock-named-state/3\0"); + hash_os_str(&mut hasher, identity.as_os_str()); + hasher.finalize().into() +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn hash_os_str(hasher: &mut Sha256, value: &OsStr) { + use std::os::unix::ffi::OsStrExt; + hasher.update(value.as_bytes()); +} + +#[cfg(any(windows, target_os = "macos"))] +fn hash_os_str(hasher: &mut Sha256, value: &OsStr) { + // Supported case-insensitive filesystems compare through uppercase-style + // mappings, and supported macOS filesystems also collapse canonical Unicode + // equivalents. Match the portable path-key policy so aliases such as Greek + // sigma/final-sigma and composed/decomposed accents share one lock. + let folded = value + .to_string_lossy() + .chars() + .flat_map(char::to_uppercase) + .nfd() + .collect::(); + hasher.update(folded.as_bytes()); +} + +#[cfg(not(any(unix, windows)))] +fn hash_os_str(hasher: &mut Sha256, value: &OsStr) { + hasher.update(value.to_string_lossy().as_bytes()); +} + +fn hex_digest(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +impl Drop for NamedStateLock { + fn drop(&mut self) { + // Closing the handle releases the OS lock even if this explicit call + // fails. In particular, abnormal process termination closes it without + // running Drop; correctness never depends on removing the lock file. + let _ = self.file.unlock(); + } +} + +#[cfg(test)] +mod tests { + use std::io::{BufRead, BufReader, Write}; + use std::process::{Command, Stdio}; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + const CHILD_DATABASE_ENV: &str = "SPOCK_HOST_NAMED_STATE_LOCK_CHILD_DATABASE"; + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + fn temp_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "spock-host-{label}-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )) + } + + #[test] + fn lock_path_is_deterministic_and_disjoint_from_database_names() { + let root = temp_root("path-shape"); + std::fs::create_dir(&root).expect("temporary root"); + let sqlite = named_state_lock_path(&root.join("world.sqlite")); + let canonical_root = std::fs::canonicalize(&root).expect("canonical temporary root"); + assert_eq!( + sqlite.parent(), + Some(canonical_root.join(LOCK_DIRECTORY).as_path()) + ); + assert_eq!( + sqlite.file_name().and_then(OsStr::to_str).map(str::len), + Some(64 + LOCK_SUFFIX.len()) + ); + assert!(sqlite + .extension() + .is_some_and(|extension| extension == "lock")); + assert_ne!(sqlite, named_state_lock_path(&root.join("world.db"))); + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + #[test] + fn lexical_aliases_share_one_lock_identity() { + let root = temp_root("alias"); + std::fs::create_dir_all(root.join("nested")).expect("temporary tree"); + let direct = root.join("world.sqlite"); + let aliased = root.join("nested/../world.sqlite"); + + let first = NamedStateLock::acquire(&direct).expect("first alias owner"); + let error = NamedStateLock::acquire(&aliased).expect_err("alias must contend"); + assert!(matches!(error, NamedStateLockError::Contended { .. })); + assert_eq!(first.lock_path(), named_state_lock_path(&aliased)); + + drop(first); + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + #[cfg(any(unix, windows))] + #[test] + fn multiply_hard_linked_database_entries_are_rejected() { + let root = temp_root("hard-link-alias"); + std::fs::create_dir(&root).expect("temporary root"); + let database = root.join("world.sqlite"); + let alias = root.join("alias.sqlite"); + std::fs::write(&database, b"existing database").expect("database fixture"); + std::fs::hard_link(&database, &alias).expect("hard-link alias"); + + for path in [&database, &alias] { + let error = NamedStateLock::acquire(path).expect_err("hard links must be rejected"); + assert!(matches!( + error, + NamedStateLockError::HardLinkedDatabase { + path: rejected, + links + } if rejected == *path && links >= 2 + )); + } + assert!(!root.join(LOCK_DIRECTORY).exists()); + + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + #[cfg(any(unix, windows))] + #[test] + fn single_link_existing_database_remains_lockable() { + let root = temp_root("single-link"); + std::fs::create_dir(&root).expect("temporary root"); + let database = root.join("world.sqlite"); + std::fs::write(&database, b"existing database").expect("database fixture"); + + let owner = NamedStateLock::acquire(&database).expect("single-link database owner"); + assert_eq!( + owner.resolved_database_path(), + std::fs::canonicalize(&root) + .expect("canonical temporary root") + .join("world.sqlite") + ); + + drop(owner); + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + #[cfg(unix)] + #[test] + fn replacing_a_final_component_symlink_cannot_change_lock_identity() { + use std::os::unix::fs::symlink; + + let root = temp_root("final-symlink"); + let real_parent = root.join("real"); + let parent_alias = root.join("parent-alias"); + std::fs::create_dir_all(&real_parent).expect("temporary tree"); + symlink(&real_parent, &parent_alias).expect("parent alias"); + + let target = root.join("target.sqlite"); + std::fs::write(&target, b"target").expect("symlink target"); + let database = parent_alias.join("link.sqlite"); + symlink(&target, &database).expect("database symlink"); + + let first = NamedStateLock::acquire(&database).expect("first owner"); + let expected_database = std::fs::canonicalize(&real_parent) + .expect("canonical parent") + .join("link.sqlite"); + assert_eq!(first.resolved_database_path(), expected_database); + + // Engine bootstrap removes this directory entry, not its target. A + // later owner using the same spelling must still address one lock. + std::fs::remove_file(&database).expect("remove final symlink"); + std::fs::write(&database, b"replacement").expect("replacement database"); + assert!(matches!( + NamedStateLock::acquire(&database), + Err(NamedStateLockError::Contended { .. }) + )); + assert_eq!(std::fs::read(&target).expect("target survives"), b"target"); + + drop(first); + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + #[cfg(any(windows, target_os = "macos"))] + #[test] + fn case_and_normalization_aliases_share_one_conservative_lock_identity() { + let root = temp_root("case-alias"); + std::fs::create_dir(&root).expect("temporary root"); + + for (left, right) in [ + ("World.sqlite", "world.sqlite"), + ("caf\u{e9}.sqlite", "cafe\u{301}.sqlite"), + ("\u{3c3}.sqlite", "\u{3c2}.sqlite"), + ] { + assert_eq!( + named_state_lock_path(&root.join(left)), + named_state_lock_path(&root.join(right)), + "{left} and {right} must share one lock identity" + ); + } + + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + #[test] + fn database_named_like_legacy_lock_cannot_replace_live_lock() { + let root = temp_root("legacy-suffix"); + std::fs::create_dir(&root).expect("temporary root"); + let database = root.join("world.sqlite"); + let legacy_lock_named_database = root.join("world.sqlite.spock.lock"); + + let first = NamedStateLock::acquire(&database).expect("first owner"); + let legacy_named = NamedStateLock::acquire(&legacy_lock_named_database) + .expect("legacy suffix is an independent database identity"); + assert_ne!(first.lock_path(), legacy_named.lock_path()); + assert_ne!(first.lock_path(), legacy_lock_named_database); + + let contract = spock_lang::compile("").expect("empty contract"); + let connection = + spock_runtime::engine::open(&contract, Some(&legacy_lock_named_database), None) + .expect("materialize database with historical lock suffix"); + assert!(first.lock_path().is_file()); + assert!(matches!( + NamedStateLock::acquire(&database), + Err(NamedStateLockError::Contended { .. }) + )); + + drop(connection); + drop(legacy_named); + drop(first); + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + #[test] + fn actual_lock_object_cannot_be_reopened_as_a_database() { + let root = temp_root("reserved-namespace"); + std::fs::create_dir(&root).expect("temporary root"); + let database = root.join("world.sqlite"); + + let first = NamedStateLock::acquire(&database).expect("first owner"); + let lock_object = first.lock_path().to_path_buf(); + let error = NamedStateLock::acquire(&lock_object) + .expect_err("the lock namespace must never accept database paths"); + assert!(matches!( + error, + NamedStateLockError::ReservedDatabasePath { path } if path == lock_object + )); + assert!(matches!( + NamedStateLock::acquire(&database), + Err(NamedStateLockError::Contended { .. }) + )); + + drop(first); + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + #[test] + fn exclusive_lock_is_nonblocking_and_released_on_drop() { + let root = temp_root("drop-release"); + let database = root.join("nested/world.sqlite"); + let expected_lock_path = named_state_lock_path(&database); + + let first = NamedStateLock::acquire(&database).expect("first owner"); + assert_eq!(first.database_path(), database); + assert_eq!( + first.resolved_database_path(), + std::fs::canonicalize(root.join("nested")) + .expect("canonical database parent") + .join("world.sqlite") + ); + assert_eq!(first.lock_path(), expected_lock_path); + assert!(expected_lock_path.is_file()); + assert!( + !database.exists(), + "locking must precede database bootstrap" + ); + + assert!(matches!( + NamedStateLock::acquire(&database), + Err(NamedStateLockError::Contended { .. }) + )); + + drop(first); + let later = NamedStateLock::acquire(&database).expect("ownership after drop"); + drop(later); + assert!( + expected_lock_path.exists(), + "the lock file is not an ownership sentinel" + ); + + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } + + /// Helper selected explicitly by `killed_subprocess_releases_os_lock`. + /// The ordinary test-harness invocation is a no-op. + #[test] + fn lock_holder_subprocess() { + let Some(database) = std::env::var_os(CHILD_DATABASE_ENV) else { + return; + }; + let _lock = NamedStateLock::acquire(PathBuf::from(database)).expect("child lock"); + println!("SPOCK_HOST_LOCK_ACQUIRED"); + std::io::stdout().flush().expect("flush lock handshake"); + + // Keep the lock live until the parent terminates this process. The + // pipe avoids sleeps and makes the crash-release test deterministic. + let mut release = String::new(); + std::io::stdin() + .read_line(&mut release) + .expect("read parent release"); + } + + #[test] + fn killed_subprocess_releases_os_lock() { + let root = temp_root("process-release"); + let database = root.join("world.sqlite"); + let current_test = std::env::current_exe().expect("current test executable"); + let mut child = Command::new(current_test) + .args([ + "--exact", + "named_state::tests::lock_holder_subprocess", + "--nocapture", + ]) + .env(CHILD_DATABASE_ENV, &database) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("spawn lock holder"); + + let stdout = child.stdout.take().expect("child stdout"); + let mut acquired = false; + for line in BufReader::new(stdout).lines() { + let line = line.expect("child output"); + if line.contains("SPOCK_HOST_LOCK_ACQUIRED") { + acquired = true; + break; + } + } + assert!(acquired, "child did not report lock acquisition"); + assert!(matches!( + NamedStateLock::acquire(&database), + Err(NamedStateLockError::Contended { .. }) + )); + + child.kill().expect("terminate lock holder without Drop"); + let status = child.wait().expect("reap lock holder"); + assert!(!status.success()); + + let recovered = + NamedStateLock::acquire(&database).expect("OS released lock after process death"); + drop(recovered); + std::fs::remove_dir_all(root).expect("remove temporary tree"); + } +} diff --git a/crates/spock-host/src/project.rs b/crates/spock-host/src/project.rs new file mode 100644 index 0000000..d01e9b7 --- /dev/null +++ b/crates/spock-host/src/project.rs @@ -0,0 +1,816 @@ +use std::fmt; +use std::fs; +use std::path::Path; +use std::sync::Arc; + +use serde_json::Value; +use sha2::{Digest, Sha256}; +use spock_project::ProjectLayout; +use spock_runtime::generation::{BackendGeneration, BackendGenerationError}; +use uhura_host::{build_candidate, capture_project_snapshot, ProjectSourceSnapshot}; + +use crate::{ + client_source_fingerprint, load_uhura_assets, observe_backend, AssetError, BackendDiagnostics, + BackendObservation, ClientHost, ClientHostError, Fingerprint, FrameworkSession, + GenerationCoordinator, HostMode, NamedStateLock, NamedStateLockError, UhuraAssetRoots, +}; + +const STABLE_CAPTURE_ATTEMPTS: usize = 4; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum ProjectComponent { + Manifest, + Backend, + Client, + Link, +} + +impl fmt::Display for ProjectComponent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::Manifest => "manifest", + Self::Backend => "backend", + Self::Client => "client", + Self::Link => "link", + }) + } +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ProjectDiagnosticPosition { + pub line: u64, + pub col: u64, +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct ProjectDiagnosticSpan { + pub offset: u64, + pub len: u64, + pub start: ProjectDiagnosticPosition, + pub end: ProjectDiagnosticPosition, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectCheckDiagnostic { + pub component: ProjectComponent, + pub code: Option, + pub rule: Option, + pub file: Option, + pub span: Option, + pub message: String, +} + +impl Ord for ProjectCheckDiagnostic { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + compare_project_diagnostics(self, other) + } +} + +impl PartialOrd for ProjectCheckDiagnostic { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl ProjectCheckDiagnostic { + fn plain(component: ProjectComponent, message: impl Into) -> Self { + Self { + component, + code: None, + rule: None, + file: None, + span: None, + message: message.into(), + } + } +} + +impl fmt::Display for ProjectCheckDiagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: ", self.component)?; + if let Some(file) = &self.file { + formatter.write_str(file)?; + if let Some(span) = self.span { + write!(formatter, ":{}:{}", span.start.line, span.start.col)?; + } + formatter.write_str(": ")?; + } + match (&self.code, &self.rule) { + (Some(code), Some(rule)) => write!(formatter, "[{code} {rule}] ")?, + (Some(code), None) => write!(formatter, "[{code}] ")?, + (None, Some(rule)) => write!(formatter, "[{rule}] ")?, + (None, None) => {} + } + formatter.write_str(&self.message) + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProjectCheckFailure { + diagnostics: Vec, +} + +impl ProjectCheckFailure { + #[must_use] + pub fn diagnostics(&self) -> &[ProjectCheckDiagnostic] { + &self.diagnostics + } + + fn push(&mut self, component: ProjectComponent, message: impl Into) { + self.diagnostics + .push(ProjectCheckDiagnostic::plain(component, message)); + } + + fn sort(&mut self) { + self.diagnostics.sort_by(compare_project_diagnostics); + self.diagnostics.dedup(); + } +} + +impl fmt::Display for ProjectCheckFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, diagnostic) in self.diagnostics.iter().enumerate() { + if index != 0 { + formatter.write_str("\n")?; + } + diagnostic.fmt(formatter)?; + } + Ok(()) + } +} + +impl std::error::Error for ProjectCheckFailure {} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackendCheckSummary { + pub tables: usize, + pub records: usize, + pub functions: usize, + pub seed_rows: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientCheckSummary { + pub source_id: String, + pub preview_count: usize, + pub replay_derived_count: usize, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectCheckReport { + pub backend: BackendCheckSummary, + pub client: Option, + /// Application-owned provider code is an explicit adapter seam in v1. + pub unchecked_links: usize, + pub warnings: Vec, +} + +/// Check every project component without binding, watching, loading browser +/// assets, acquiring a named-state lock, or touching a named database. +pub fn check_project(layout: &ProjectLayout) -> Result { + let mut failures = ProjectCheckFailure::default(); + let mut warnings = Vec::new(); + + let backend = match observe_backend(layout).into_captured_backend() { + Ok(captured) => match BackendGeneration::from_captured(captured, None) { + Ok(generation) => match generation.authority_router() { + Ok(_) => Some(BackendCheckSummary { + tables: generation.contract().tables.len(), + records: generation.contract().records.len(), + functions: generation.contract().fns.len(), + seed_rows: generation.contract().seed.len(), + }), + Err(error) => { + failures.push(ProjectComponent::Backend, error.to_string()); + None + } + }, + Err(error) => { + failures.push(ProjectComponent::Backend, error.to_string()); + None + } + }, + Err(diagnostics) => { + for diagnostic in diagnostics.iter() { + failures.push(ProjectComponent::Backend, diagnostic.to_string()); + } + None + } + }; + + let client = match &layout.client { + None => None, + Some(client_layout) => match capture_stable_client(client_layout.root.absolute()) { + Ok(snapshot) => { + let candidate = build_candidate(&snapshot, 1); + let summary = candidate.summary(); + let diagnostics = candidate.diagnostics(); + collect_uhura_diagnostics(diagnostics.editor, &mut failures, &mut warnings); + collect_uhura_diagnostics(diagnostics.play, &mut failures, &mut warnings); + if summary.editor_current && summary.play_ok { + Some(ClientCheckSummary { + source_id: candidate.source_id(), + preview_count: summary.preview_count.unwrap_or(0), + replay_derived_count: summary.replay_derived_count.unwrap_or(0), + }) + } else { + if !failures + .diagnostics + .iter() + .any(|diagnostic| diagnostic.component == ProjectComponent::Client) + { + failures.push( + ProjectComponent::Client, + "Uhura candidate was rejected without a structured diagnostic", + ); + } + None + } + } + Err(message) => { + failures.push(ProjectComponent::Client, message); + None + } + }, + }; + + if layout.client.is_some() { + warnings.push(ProjectCheckDiagnostic::plain( + ProjectComponent::Link, + "application-owned provider adapter code remains unchecked", + )); + } + + if !failures.diagnostics.is_empty() { + failures.sort(); + return Err(failures); + } + + warnings.sort_by(compare_project_diagnostics); + warnings.dedup(); + + Ok(ProjectCheckReport { + backend: backend.expect("failure returned when backend summary is absent"), + client, + unchecked_links: usize::from(layout.client.is_some()), + warnings, + }) +} + +fn compare_project_diagnostics( + left: &ProjectCheckDiagnostic, + right: &ProjectCheckDiagnostic, +) -> std::cmp::Ordering { + left.component + .cmp(&right.component) + .then_with(|| left.file.cmp(&right.file)) + .then_with(|| left.span.cmp(&right.span)) + .then_with(|| left.code.cmp(&right.code)) + .then_with(|| left.rule.cmp(&right.rule)) + .then_with(|| left.message.cmp(&right.message)) +} + +fn collect_uhura_diagnostics( + envelope: &Value, + failures: &mut ProjectCheckFailure, + warnings: &mut Vec, +) { + let Some(diagnostics) = envelope.get("diagnostics").and_then(Value::as_array) else { + return; + }; + for diagnostic in diagnostics { + let message = diagnostic + .get("message") + .and_then(Value::as_str) + .unwrap_or("client check diagnostic"); + let structured = ProjectCheckDiagnostic { + component: ProjectComponent::Client, + code: diagnostic + .get("code") + .and_then(Value::as_str) + .map(str::to_owned), + rule: diagnostic + .get("rule") + .and_then(Value::as_str) + .map(str::to_owned), + file: diagnostic + .get("file") + .and_then(Value::as_str) + .map(str::to_owned), + span: diagnostic.get("span").and_then(parse_diagnostic_span), + message: message.to_owned(), + }; + if diagnostic.get("severity").and_then(Value::as_str) == Some("error") { + failures.diagnostics.push(structured); + } else { + warnings.push(structured); + } + } +} + +fn parse_diagnostic_span(value: &Value) -> Option { + Some(ProjectDiagnosticSpan { + offset: value.get("offset")?.as_u64()?, + len: value.get("len")?.as_u64()?, + start: parse_diagnostic_position(value.get("start")?)?, + end: parse_diagnostic_position(value.get("end")?)?, + }) +} + +fn parse_diagnostic_position(value: &Value) -> Option { + Some(ProjectDiagnosticPosition { + line: value.get("line")?.as_u64()?, + col: value.get("col")?.as_u64()?, + }) +} + +#[derive(Debug, thiserror::Error)] +pub enum HostError { + #[error("project topology is invalid:\n{0}")] + ProjectTopology(#[from] spock_project::Diagnostics), + #[error("backend input capture failed:\n{0}")] + BackendCapture(BackendDiagnostics), + #[error("backend generation failed: {0}")] + BackendGeneration(#[from] BackendGenerationError), + #[error("backend route construction failed: {0}")] + BackendRoutes(#[from] spock_runtime::http::StartupError), + #[error("client capture failed: {0}")] + ClientCapture(String), + #[error("configured client is invalid:\n{0}")] + ClientInvalid(String), + #[error(transparent)] + Assets(#[from] AssetError), + #[error(transparent)] + ClientHost(#[from] ClientHostError), + #[error(transparent)] + NamedState(#[from] NamedStateLockError), + #[error("project inputs changed while the initial generation was being prepared; save all files and retry")] + UnstableProject, +} + +pub(crate) struct PreparedProject { + pub layout: Arc, + pub session: Arc, + pub active_topology: Fingerprint, + pub active_backend: BackendObservation, + pub _named_state_lock: Option, +} + +pub(crate) fn prepare_project( + layout: ProjectLayout, + mode: HostMode, + database_path: Option<&Path>, + asset_roots: Option, +) -> Result { + // Refresh the caller's discovered root at the construction boundary. A + // manifest save between CLI discovery and host preparation cannot leave + // us serving paths parsed from one topology under another fingerprint. + let layout = reload_project_layout(&layout)?; + let active_topology = topology_fingerprint(&layout.manifest_path); + let active_backend = observe_backend(&layout); + let captured = active_backend + .captured_backend() + .cloned() + .ok_or_else(|| HostError::BackendCapture(active_backend.diagnostics().clone()))?; + + // Prove the full backend load in memory before a client failure or missing + // asset can cause a named database to be touched. + let validation_backend = BackendGeneration::from_captured(captured.clone(), None)?; + let _ = validation_backend.authority_router()?; + + let (initial_client, client_diagnostics, web) = if let Some(client_layout) = &layout.client { + let snapshot = capture_stable_client(client_layout.root.absolute()) + .map_err(HostError::ClientCapture)?; + let candidate = build_candidate(&snapshot, 1); + let summary = candidate.summary(); + let diagnostic_messages = candidate_diagnostic_messages(&candidate); + let diagnostic_text = candidate_diagnostic_text(&candidate); + if mode == HostMode::Start && (!summary.editor_current || !summary.play_ok) { + return Err(HostError::ClientInvalid(diagnostic_text)); + } + let web = match asset_roots { + Some(roots) => roots.load()?, + None => load_uhura_assets()?, + }; + (Some(snapshot), Some(diagnostic_messages), Some(web)) + } else { + (None, None, None) + }; + let client_fingerprint = initial_client.as_ref().map(client_source_fingerprint); + + // Cross-subsystem construction used only immutable captures. Recheck the + // resolved topology, backend, and client before acquiring a named-state + // lock or opening a database; any overlapping save or safe symlink retarget + // makes this attempt ineligible. + verify_initial_inputs_stable( + &layout, + &active_topology, + &active_backend, + client_fingerprint.as_ref(), + )?; + + let (named_state_lock, backend) = match database_path { + Some(path) => { + let lock = NamedStateLock::acquire(path)?; + let backend = + BackendGeneration::from_captured(captured, Some(lock.resolved_database_path()))?; + (Some(lock), backend) + } + None => (None, validation_backend), + }; + + let mut coordinator = GenerationCoordinator::activated( + mode, + active_backend.fingerprint().clone(), + active_topology.clone(), + client_fingerprint.clone(), + uuid::Uuid::now_v7().to_string(), + ); + + let client_host = match (web, initial_client.as_ref()) { + (Some(web), Some(snapshot)) => { + let revision = coordinator.observed_revision(); + coordinator + .begin_client_attempt(revision) + .expect("initial revision is newest"); + let (host, publication) = ClientHost::activate(web, snapshot, revision)?; + if publication.report.editor_current && publication.report.play_ok { + coordinator + .publish_client( + revision, + publication.report.source_revision, + client_fingerprint + .clone() + .expect("configured client has a fingerprint"), + client_diagnostics.unwrap_or_default(), + ) + .expect("initial client attempt was started"); + } else { + coordinator + .reject_client( + revision, + match client_diagnostics { + Some(diagnostics) if !diagnostics.is_empty() => diagnostics, + _ => vec!["Uhura client candidate was rejected".to_string()], + }, + ) + .expect("initial client attempt was started"); + } + Some(host) + } + (None, None) => None, + _ => unreachable!("client snapshot and browser assets are prepared together"), + }; + + Ok(PreparedProject { + layout: Arc::new(layout), + session: Arc::new(FrameworkSession::new(backend, client_host, coordinator)), + active_topology, + active_backend, + _named_state_lock: named_state_lock, + }) +} + +fn reload_project_layout( + layout: &ProjectLayout, +) -> Result { + let refreshed_root = + match spock_project::resolve_target(Some(layout.manifest_path.as_path()), &layout.root)? { + spock_project::ResolvedTarget::Project(root) => root, + spock_project::ResolvedTarget::SpockFile(_) => { + unreachable!("an explicit spock.toml target cannot select file mode") + } + }; + spock_project::load_project(&refreshed_root) +} + +fn verify_initial_inputs_stable( + layout: &ProjectLayout, + active_topology: &Fingerprint, + active_backend: &BackendObservation, + active_client: Option<&Fingerprint>, +) -> Result<(), HostError> { + let settled_layout = reload_project_layout(layout).map_err(|_| HostError::UnstableProject)?; + if &settled_layout != layout + || &topology_fingerprint(&settled_layout.manifest_path) != active_topology + { + return Err(HostError::UnstableProject); + } + + let settled_backend = observe_backend(&settled_layout); + if settled_backend.fingerprint() != active_backend.fingerprint() { + return Err(HostError::UnstableProject); + } + + let settled_client = settled_layout + .client + .as_ref() + .map(|client| { + capture_stable_client(client.root.absolute()) + .map(|snapshot| client_source_fingerprint(&snapshot)) + }) + .transpose() + .map_err(|_| HostError::UnstableProject)?; + if settled_client.as_ref() != active_client { + return Err(HostError::UnstableProject); + } + Ok(()) +} + +pub(crate) fn capture_stable_client(root: &Path) -> Result { + let mut previous = capture_project_snapshot(root); + for _ in 1..STABLE_CAPTURE_ATTEMPTS { + let current = capture_project_snapshot(root); + if current.fingerprint() == previous.fingerprint() { + return Ok(current); + } + previous = current; + } + Err(format!( + "client inputs under {} did not remain unchanged across {STABLE_CAPTURE_ATTEMPTS} consecutive captures", + root.display() + )) +} + +#[must_use] +pub fn topology_fingerprint(manifest_path: &Path) -> Fingerprint { + let mut hasher = Sha256::new(); + hasher.update(b"spock-project-topology/1\0"); + match fs::symlink_metadata(manifest_path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + hasher.update(b"symlink\0"); + match fs::read_link(manifest_path) { + Ok(target) => { + hash_field(&mut hasher, target.as_os_str().to_string_lossy().as_bytes()) + } + Err(error) => hash_field(&mut hasher, error.to_string().as_bytes()), + } + } + Ok(metadata) if metadata.is_file() => { + hasher.update(b"file\0"); + match fs::read(manifest_path) { + Ok(bytes) => hash_field(&mut hasher, &bytes), + Err(error) => hash_field(&mut hasher, error.to_string().as_bytes()), + } + } + Ok(metadata) if metadata.is_dir() => hasher.update(b"directory\0"), + Ok(_) => hasher.update(b"other\0"), + Err(error) => { + hasher.update(b"unavailable\0"); + hash_field(&mut hasher, error.to_string().as_bytes()); + } + } + Fingerprint::new(hex_digest(hasher.finalize().as_slice())) +} + +fn hash_field(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +fn hex_digest(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn candidate_diagnostic_text(candidate: &uhura_host::ClientCandidate) -> String { + let diagnostics = candidate.diagnostics(); + let values = [diagnostics.editor, diagnostics.play] + .into_iter() + .filter(|value| !value.is_null()) + .map(|value| serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string())) + .collect::>(); + if values.is_empty() { + "Uhura client candidate has no diagnostics".to_string() + } else { + values.join("\n") + } +} + +fn candidate_diagnostic_messages(candidate: &uhura_host::ClientCandidate) -> Vec { + let diagnostics = candidate.diagnostics(); + [diagnostics.editor, diagnostics.play] + .into_iter() + .filter(|value| !value.is_null()) + .map(|value| serde_json::to_string(value).unwrap_or_else(|_| value.to_string())) + .collect() +} + +#[cfg(test)] +mod tests { + use spock_project::{load_project_from, ProjectManifest}; + use tempfile::tempdir; + + use super::*; + + fn write_client(root: &Path) { + for file in spock_project::minimal_uhura_client_template().files() { + let path = root.join(file.path().as_path()); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, file.contents()).unwrap(); + } + } + + fn write_project(root: &Path, backend: &str, with_client: bool) -> ProjectLayout { + fs::create_dir_all(root.join("backend")).unwrap(); + fs::write(root.join("backend/app.spock"), backend).unwrap(); + if with_client { + write_client(&root.join("client")); + } + let manifest = ProjectManifest::new( + "demo", + "backend", + "app.spock", + with_client.then_some("client"), + ) + .unwrap(); + fs::write(root.join("spock.toml"), manifest.to_toml_string()).unwrap(); + load_project_from(root).unwrap() + } + + #[test] + fn project_check_accepts_backend_only_and_empty_full_stack_projects() { + let backend = tempdir().unwrap(); + let backend_layout = write_project(backend.path(), "", false); + let report = check_project(&backend_layout).unwrap(); + assert_eq!( + report.backend, + BackendCheckSummary { + tables: 0, + records: 0, + functions: 0, + seed_rows: 0, + } + ); + assert!(report.client.is_none()); + + let full = tempdir().unwrap(); + let full_layout = write_project(full.path(), "", true); + let report = check_project(&full_layout).unwrap(); + assert!(report.client.is_some()); + assert_eq!(report.unchecked_links, 1); + } + + #[test] + fn project_check_aggregates_backend_and_client_failures() { + let temp = tempdir().unwrap(); + let layout = write_project(temp.path(), "table broken {", true); + fs::write( + temp.path().join("client/app/home/page.uhura"), + "not valid uhura", + ) + .unwrap(); + + let failure = check_project(&layout).unwrap_err(); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.component == ProjectComponent::Backend)); + assert!(failure + .diagnostics() + .iter() + .any(|diagnostic| diagnostic.component == ProjectComponent::Client)); + let located = failure + .diagnostics() + .iter() + .find(|diagnostic| { + diagnostic.component == ProjectComponent::Client + && diagnostic.file.is_some() + && diagnostic.span.is_some() + }) + .expect("Uhura source diagnostic should retain its file and span"); + assert!(located.code.is_some()); + assert!(located.rule.is_some()); + let rendered = located.to_string(); + assert!(rendered.contains(":"), "{rendered}"); + assert!(rendered.contains('['), "{rendered}"); + } + + #[test] + fn topology_identity_changes_for_bytes_and_unsafe_entry_kinds() { + let temp = tempdir().unwrap(); + let path = temp.path().join("spock.toml"); + fs::write(&path, "version = 1\n").unwrap(); + let first = topology_fingerprint(&path); + fs::write(&path, "version = 2\n").unwrap(); + let second = topology_fingerprint(&path); + assert_ne!(first, second); + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + assert_ne!(second, topology_fingerprint(&path)); + } + + #[test] + fn candidate_diagnostic_text_is_not_empty_for_invalid_snapshot() { + let temp = tempdir().unwrap(); + let snapshot = capture_project_snapshot(temp.path()); + let candidate = build_candidate(&snapshot, 1); + let rendered = candidate_diagnostic_text(&candidate); + assert!(rendered.contains("uhura-diagnostics"), "{rendered}"); + } + + #[test] + fn in_memory_prepare_never_creates_named_state() { + let temp = tempdir().unwrap(); + let layout = write_project(temp.path(), "", false); + let prepared = prepare_project(layout, HostMode::Start, None, None).unwrap(); + assert!(prepared._named_state_lock.is_none()); + assert_eq!(prepared.session.status().mode, HostMode::Start); + assert_eq!( + prepared + .session + .backend() + .input_fingerprint() + .unwrap() + .as_str(), + prepared.active_backend.fingerprint().as_str() + ); + } + + #[test] + fn each_prepared_backend_world_has_a_unique_session_identity() { + let temp = tempdir().unwrap(); + let layout = write_project(temp.path(), "", false); + let first = prepare_project(layout, HostMode::Start, None, None).unwrap(); + let first_world = first.session.status().backend.world_id; + drop(first); + + let layout = load_project_from(temp.path()).unwrap(); + let second = prepare_project(layout, HostMode::Start, None, None).unwrap(); + assert_ne!(first_world, second.session.status().backend.world_id); + } + + #[test] + fn final_stability_barrier_rejects_a_client_edit() { + let temp = tempdir().unwrap(); + let layout = write_project(temp.path(), "", true); + let active_topology = topology_fingerprint(&layout.manifest_path); + let active_backend = observe_backend(&layout); + let active_client = client_source_fingerprint( + &capture_stable_client(layout.client.as_ref().unwrap().root.absolute()).unwrap(), + ); + + verify_initial_inputs_stable( + &layout, + &active_topology, + &active_backend, + Some(&active_client), + ) + .expect("unchanged project should remain eligible"); + + fs::write( + temp.path().join("client/app/home/page.uhura"), + "this source changed during preparation\n", + ) + .unwrap(); + assert!(matches!( + verify_initial_inputs_stable( + &layout, + &active_topology, + &active_backend, + Some(&active_client), + ), + Err(HostError::UnstableProject) + )); + } + + #[cfg(unix)] + #[test] + fn final_stability_barrier_re_resolves_a_client_root_symlink() { + use std::os::unix::fs::symlink; + + let temp = tempdir().unwrap(); + fs::create_dir(temp.path().join("backend")).unwrap(); + fs::write(temp.path().join("backend/app.spock"), "").unwrap(); + write_client(&temp.path().join("client-a")); + write_client(&temp.path().join("client-b")); + symlink("client-a", temp.path().join("client")).unwrap(); + let manifest = + ProjectManifest::new("demo", "backend", "app.spock", Some("client")).unwrap(); + fs::write(temp.path().join("spock.toml"), manifest.to_toml_string()).unwrap(); + + let layout = load_project_from(temp.path()).unwrap(); + let active_topology = topology_fingerprint(&layout.manifest_path); + let active_backend = observe_backend(&layout); + let active_client = client_source_fingerprint( + &capture_stable_client(layout.client.as_ref().unwrap().root.absolute()).unwrap(), + ); + + fs::remove_file(temp.path().join("client")).unwrap(); + symlink("client-b", temp.path().join("client")).unwrap(); + assert!(matches!( + verify_initial_inputs_stable( + &layout, + &active_topology, + &active_backend, + Some(&active_client), + ), + Err(HostError::UnstableProject) + )); + } +} diff --git a/crates/spock-host/src/routing.rs b/crates/spock-host/src/routing.rs new file mode 100644 index 0000000..412ab25 --- /dev/null +++ b/crates/spock-host/src/routing.rs @@ -0,0 +1,219 @@ +/// Exclusive owner selected before any subsystem fallback runs. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RouteOwner { + Framework, + Authority, + Client, + ProtocolNotFound, + NotFound, +} + +/// Classify one URI path into the combined host's non-overlapping route map. +/// +/// Query strings are transport metadata and must be removed by the caller. +/// Reserved protocol namespaces never reach the client's history fallback. +#[must_use] +pub fn classify_route(path: &str, client_configured: bool) -> RouteOwner { + let raw_path = path; + let Some(decoded) = decode_for_ownership(path) else { + return RouteOwner::ProtocolNotFound; + }; + let path = decoded.as_str(); + + if path == "/~health" || path == "/~project" || path.starts_with("/~project/") { + return RouteOwner::Framework; + } + + if authority_path(path) { + return RouteOwner::Authority; + } + + if client_configured && client_path(raw_path) { + return RouteOwner::Client; + } + + // The host delegates the original URI to Uhura. If decoding is what made + // this look like a canonical client namespace, delegation would disagree + // about ownership and could turn a protocol URL into the SPA fallback. + if client_configured && client_path(path) { + return RouteOwner::ProtocolNotFound; + } + + if reserved_protocol_path(path) { + return RouteOwner::ProtocolNotFound; + } + + if client_configured { + RouteOwner::Client + } else if path == "/" { + RouteOwner::Framework + } else { + RouteOwner::NotFound + } +} + +/// Decode exactly one percent-encoding layer for namespace ownership only. +/// +/// The original URI is still passed to the owning subsystem. This prevents an +/// encoded spelling of a reserved protocol namespace from reaching the client +/// history fallback without changing asset identity or decoding twice. +fn decode_for_ownership(path: &str) -> Option { + if !path.as_bytes().contains(&b'%') { + return (!path.contains('\\') && !path.contains('\0')).then(|| path.to_string()); + } + let bytes = path.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] != b'%' { + decoded.push(bytes[index]); + index += 1; + continue; + } + let high = *bytes.get(index + 1)?; + let low = *bytes.get(index + 2)?; + decoded.push(hex_value(high)? << 4 | hex_value(low)?); + index += 3; + } + let decoded = String::from_utf8(decoded).ok()?; + (!decoded.contains('\\') && !decoded.contains('\0')).then_some(decoded) +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn authority_path(path: &str) -> bool { + path == "/~contract" + || path == "/~personas" + || path == "/~whoami" + || path == "/~studio" + || path.starts_with("/~studio/") + || path == "/graphql/v1" + || path.starts_with("/graphql/v1/") + || path == "/rest/v1" + || path.starts_with("/rest/v1/") + || path == "/storage/v1" + || path.starts_with("/storage/v1/") +} + +fn client_path(path: &str) -> bool { + path == "/" + || path == "/play" + || path.starts_with("/play/") + || path == "/favicon.ico" + || path == "/assets" + || path.starts_with("/assets/") + || path == "/api/editor" + || path.starts_with("/api/editor/") + || path == "/api/play" + || path.starts_with("/api/play/") +} + +fn reserved_protocol_path(path: &str) -> bool { + path.starts_with("/~") + || ["/api", "/graphql", "/rest", "/storage"] + .iter() + .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_surfaces_have_one_owner() { + let cases = [ + ("/~health", RouteOwner::Framework), + ("/~project/status", RouteOwner::Framework), + ("/~studio", RouteOwner::Authority), + ("/~studio/assets/app.js", RouteOwner::Authority), + ("/~contract", RouteOwner::Authority), + ("/~personas", RouteOwner::Authority), + ("/graphql/v1", RouteOwner::Authority), + ("/rest/v1/rpc/do-thing", RouteOwner::Authority), + ("/storage/v1/object/id", RouteOwner::Authority), + ("/", RouteOwner::Client), + ("/play", RouteOwner::Client), + ("/assets/app.js", RouteOwner::Client), + ("/api/editor/state", RouteOwner::Client), + ("/api/play/ir.json", RouteOwner::Client), + ("/profile/mira", RouteOwner::Client), + ]; + for (path, expected) in cases { + assert_eq!(classify_route(path, true), expected, "{path}"); + } + } + + #[test] + fn unknown_protocol_paths_never_reach_the_client_spa() { + for path in [ + "/~unknown", + "/api/unknown", + "/graphql/v2", + "/rest/v2/users", + "/storage/v2/object", + ] { + assert_eq!( + classify_route(path, true), + RouteOwner::ProtocolNotFound, + "{path}" + ); + } + } + + #[test] + fn backend_only_root_is_framework_owned_and_other_pages_are_not_found() { + assert_eq!(classify_route("/", false), RouteOwner::Framework); + assert_eq!(classify_route("/profile/mira", false), RouteOwner::NotFound); + assert_eq!( + classify_route("/api/play/ir.json", false), + RouteOwner::ProtocolNotFound + ); + } + + #[test] + fn similarly_prefixed_non_protocol_names_can_be_client_routes() { + for path in ["/apiary", "/restroom", "/graphical", "/storage-unit"] { + assert_eq!(classify_route(path, true), RouteOwner::Client, "{path}"); + } + } + + #[test] + fn encoded_reserved_namespaces_never_reach_the_client_spa() { + for path in [ + "/%61pi/unknown", + "/%61pi/editor/state", + "/api%2Feditor/state", + "/%70lay", + "/%7Eunknown", + "/graphql%2Fv2", + "/re%73t/v2/users", + "/storage%2fv2/object", + "/api/%00bad", + "/api/%GG", + ] { + assert_eq!( + classify_route(path, true), + RouteOwner::ProtocolNotFound, + "{path}" + ); + } + } + + #[test] + fn encoding_inside_an_owned_client_asset_path_remains_client_owned() { + for path in [ + "/assets/app%20shell.js", + "/api/play/assets/summer%20album.jpg", + "/api/play/assets/folder%2Fcover.jpg", + ] { + assert_eq!(classify_route(path, true), RouteOwner::Client, "{path}"); + } + } +} diff --git a/crates/spock-host/src/server.rs b/crates/spock-host/src/server.rs new file mode 100644 index 0000000..e012a02 --- /dev/null +++ b/crates/spock-host/src/server.rs @@ -0,0 +1,1768 @@ +use std::future::Future; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use spock_project::ProjectLayout; +use tokio::task::JoinHandle; +use uhura_host::ProjectSourceSnapshot; + +use crate::project::{capture_stable_client, prepare_project, PreparedProject}; +use crate::{ + client_source_fingerprint, observe_backend, topology_fingerprint, BackendFreshness, + BackendObservation, ClientAttemptState, ClientHost, ClientHostError, ClientPublication, + Fingerprint, FrameworkSession, HostError, HostMode, Observation, ObservationDisposition, + ObservedRevision, PreparedClient, UhuraAssetRoots, +}; + +const COHERENT_FRAME_ATTEMPTS: usize = 4; + +#[derive(Clone, Debug)] +pub struct ServeOptions { + pub bind: SocketAddr, + pub database_path: Option, + pub asset_roots: Option, + pub poll_interval: Duration, +} + +impl Default for ServeOptions { + fn default() -> Self { + Self { + bind: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4000), + database_path: None, + asset_roots: None, + poll_interval: Duration::from_millis(250), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum HostNotice { + DevelopmentPolicy, + Listening { + address: SocketAddr, + client_configured: bool, + }, + ClientBuilding { + observed_revision: u64, + }, + ClientPublished { + observed_revision: u64, + source_revision: u64, + play_generation: u64, + }, + ClientRejected { + observed_revision: u64, + diagnostics: Vec, + serving_last_good: bool, + }, + BackendRestartRequired { + changed_inputs: Vec, + diagnostics: Vec, + }, + BackendReverted, + ObserverError { + message: String, + }, +} + +#[derive(Clone)] +pub struct HostNoticeSink(Arc); + +impl HostNoticeSink { + pub fn new(callback: impl Fn(HostNotice) + Send + Sync + 'static) -> Self { + Self(Arc::new(callback)) + } + + fn emit(&self, notice: HostNotice) { + (self.0)(notice); + } +} + +impl Default for HostNoticeSink { + fn default() -> Self { + Self::new(|_| {}) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ServeOutcome { + pub local_address: SocketAddr, +} + +#[derive(Debug, thiserror::Error)] +pub enum ServeError { + #[error(transparent)] + Prepare(#[from] HostError), + #[error("could not bind framework host at {address}: {source}")] + Bind { + address: SocketAddr, + #[source] + source: std::io::Error, + }, + #[error("could not inspect bound framework listener: {0}")] + LocalAddress(std::io::Error), + #[error("could not start backend generation lifecycle: {0}")] + BackendLifecycle(#[from] spock_runtime::generation::BackendLifecycleError), + #[error("framework server failed: {0}")] + Serve(std::io::Error), + #[error("development observer failed: {0}")] + Observer(#[from] tokio::task::JoinError), +} + +/// Prepare, bind, and serve one fixed or watched framework project. +/// +/// `start` and `dev` share the same preparation proof and one listener. The +/// development observer never constructs a second backend generation. +pub async fn serve_project( + layout: ProjectLayout, + mode: HostMode, + options: ServeOptions, + notices: HostNoticeSink, + shutdown: F, +) -> Result +where + F: Future + Send, +{ + let prepared = prepare_project( + layout, + mode, + options.database_path.as_deref(), + options.asset_roots, + )?; + let router = prepared.session.router().map_err(HostError::from)?; + let listener = tokio::net::TcpListener::bind(options.bind) + .await + .map_err(|source| ServeError::Bind { + address: options.bind, + source, + })?; + let local_address = listener.local_addr().map_err(ServeError::LocalAddress)?; + let lifecycle = prepared.session.backend().start_background_tasks()?; + + if mode == HostMode::Dev { + notices.emit(HostNotice::DevelopmentPolicy); + } + notices.emit(HostNotice::Listening { + address: local_address, + client_configured: prepared.layout.client.is_some(), + }); + + let observer_stop = Arc::new(AtomicBool::new(false)); + let observer = if mode == HostMode::Dev { + Some(spawn_observer( + &prepared, + options.poll_interval, + Arc::clone(&observer_stop), + notices.clone(), + )) + } else { + None + }; + + let shutdown_session = Arc::clone(&prepared.session); + let shutdown_observer = Arc::clone(&observer_stop); + let server_result = serve_router_until_shutdown(listener, router, shutdown, move || { + // Stop producing new observations and close host-owned streaming + // bodies as soon as the listener begins graceful shutdown. Axum can + // then drain every accepted connection instead of waiting forever on + // SSE, while the backend generation and named-state lock stay alive. + shutdown_observer.store(true, Ordering::Release); + shutdown_session.shutdown_streams(); + }) + .await + .map_err(ServeError::Serve); + + observer_stop.store(true, Ordering::Release); + prepared.session.shutdown_streams(); + let observer_result = match observer { + Some(observer) => observer.await, + None => Ok(()), + }; + lifecycle.shutdown().await; + observer_result?; + server_result?; + + // `prepared` deliberately remains alive through listener, observer, SSE, + // and backend-task shutdown. Its final field owns the named-state lock, + // which is released only after the session/database handles are dropped. + drop(prepared); + Ok(ServeOutcome { local_address }) +} + +async fn serve_router_until_shutdown( + listener: tokio::net::TcpListener, + router: axum::Router, + shutdown: F, + on_shutdown: C, +) -> std::io::Result<()> +where + F: Future + Send, + C: FnOnce(), +{ + let (graceful_tx, graceful_rx) = tokio::sync::oneshot::channel(); + let server = async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = graceful_rx.await; + }) + .await + }; + tokio::pin!(server); + tokio::pin!(shutdown); + tokio::select! { + result = &mut server => result, + () = &mut shutdown => { + on_shutdown(); + let _ = graceful_tx.send(()); + server.await + } + } +} + +fn spawn_observer( + prepared: &PreparedProject, + poll_interval: Duration, + stop: Arc, + notices: HostNoticeSink, +) -> JoinHandle<()> { + let layout = Arc::clone(&prepared.layout); + let session = Arc::clone(&prepared.session); + let active_backend = prepared.active_backend.clone(); + let active_topology = prepared.active_topology.clone(); + + tokio::task::spawn_blocking(move || { + let mut force_client_build = false; + while !stop.load(Ordering::Acquire) { + std::thread::sleep(poll_interval); + if stop.load(Ordering::Acquire) { + break; + } + + let frame = match capture_frame(&layout) { + Ok(frame) => frame, + Err(message) => { + notices.emit(HostNotice::ObserverError { message }); + continue; + } + }; + let disposition = apply_frame( + &session, + &active_backend, + &active_topology, + &frame, + ¬ices, + ); + let client_changed = matches!( + disposition, + ObservationDisposition::Changed { + client_changed: true, + .. + } + ); + if !client_changed && !force_client_build { + continue; + } + let Some(client_observation) = frame.client.as_ref() else { + force_client_build = false; + continue; + }; + let client_host_configured = session + .publication() + .read() + .expect("project publication lock") + .client + .is_some(); + if !client_host_configured { + // A valid topology edit may add a client to a backend-only + // active session. That requires process reconstruction; never + // leave the absent client state stuck in `building`. + force_client_build = false; + continue; + } + force_client_build = false; + + let observed_revision = { + let publication_state = session.publication(); + let mut publication = publication_state.write().expect("project publication lock"); + let revision = publication.coordinator.observed_revision(); + if let Err(error) = publication.coordinator.begin_client_attempt(revision) { + notices.emit(HostNotice::ObserverError { + message: error.to_string(), + }); + continue; + } + revision + }; + session.events().publish(); + notices.emit(HostNotice::ClientBuilding { + observed_revision: observed_revision.get(), + }); + + if let Some(diagnostics) = client_observation.diagnostics() { + let diagnostics = diagnostics.to_vec(); + let rejection = session + .publication() + .write() + .expect("project publication lock") + .coordinator + .reject_client(observed_revision, diagnostics.clone()); + match rejection { + Ok(()) => { + session.events().publish(); + notices.emit(HostNotice::ClientRejected { + observed_revision: observed_revision.get(), + diagnostics, + serving_last_good: session.status().client.active.is_some(), + }); + } + Err(error) => notices.emit(HostNotice::ObserverError { + message: error.to_string(), + }), + } + continue; + } + let snapshot = client_observation + .snapshot() + .expect("a client observation without diagnostics has a captured snapshot"); + + let candidate = { + let publication_state = session.publication(); + let publication = publication_state.read().expect("project publication lock"); + let Some(client) = &publication.client else { + continue; + }; + client.prepare(snapshot, observed_revision) + }; + let diagnostics = prepared_client_diagnostics(&candidate); + + // The build may have overlapped another save. Re-observe every + // subsystem before publication; a newer project revision makes + // this result permanently ineligible. + let latest = match capture_frame(&layout) { + Ok(frame) => frame, + Err(message) => { + notices.emit(HostNotice::ObserverError { message }); + // The attempt is already visible as Building. Retry this + // same observed revision even when the next coherent + // frame has the same fingerprint, so capture instability + // cannot strand client status indefinitely. + force_client_build = true; + continue; + } + }; + let _ = apply_frame( + &session, + &active_backend, + &active_topology, + &latest, + ¬ices, + ); + let newest_revision = session + .publication() + .read() + .expect("project publication lock") + .coordinator + .observed_revision(); + if newest_revision != observed_revision { + force_client_build = true; + continue; + } + + let publication_result = + publish_client_candidate(&session, candidate, newest_revision, diagnostics.clone()); + + match publication_result { + ClientPublicationAttempt::Completed(publication) => { + if publication.report.editor_current && publication.report.play_ok { + notices.emit(HostNotice::ClientPublished { + observed_revision: newest_revision.get(), + source_revision: publication.report.source_revision, + play_generation: publication.report.play_generation, + }); + } else { + notices.emit(HostNotice::ClientRejected { + observed_revision: newest_revision.get(), + diagnostics, + serving_last_good: publication.report.has_good_play, + }); + } + } + ClientPublicationAttempt::Failed { + message, + diagnostics, + serving_last_good, + } => { + notices.emit(HostNotice::ClientRejected { + observed_revision: newest_revision.get(), + diagnostics, + serving_last_good, + }); + notices.emit(HostNotice::ObserverError { message }); + } + } + } + }) +} + +enum ClientPublicationAttempt { + Completed(ClientPublication), + Failed { + message: String, + diagnostics: Vec, + serving_last_good: bool, + }, +} + +fn publish_client_candidate( + session: &FrameworkSession, + candidate: PreparedClient, + newest_revision: ObservedRevision, + diagnostics: Vec, +) -> ClientPublicationAttempt { + publish_client_candidate_with( + session, + candidate, + newest_revision, + diagnostics, + ClientHost::publish, + ) +} + +fn publish_client_candidate_with( + session: &FrameworkSession, + candidate: PreparedClient, + newest_revision: ObservedRevision, + diagnostics: Vec, + publish: impl FnOnce( + &mut ClientHost, + PreparedClient, + ObservedRevision, + ) -> Result, +) -> ClientPublicationAttempt { + // Client publication and status mutation share one writer lock. Readers + // see entirely the old or new publication; invalidate only after release. + let outcome = { + let publication_state = session.publication(); + let mut publication = publication_state.write().expect("project publication lock"); + publish_client_candidate_locked_with( + &mut publication, + candidate, + newest_revision, + diagnostics, + publish, + ) + }; + session.events().publish(); + outcome +} + +fn publish_client_candidate_locked_with( + publication: &mut crate::http::PublicationState, + candidate: PreparedClient, + newest_revision: ObservedRevision, + diagnostics: Vec, + publish: impl FnOnce( + &mut ClientHost, + PreparedClient, + ObservedRevision, + ) -> Result, +) -> ClientPublicationAttempt { + let summary = candidate.summary(); + let source_revision = candidate.source_revision(); + let source_fingerprint = candidate.source_fingerprint().clone(); + + // Prove the coordinator transition on a clone before changing the Uhura + // host. Once Uhura accepts the candidate, installing this already-validated + // coordinator is infallible and both become visible under the same lock. + let mut completed_coordinator = publication.coordinator.clone(); + if summary.editor_current && summary.play_ok { + completed_coordinator + .publish_client( + newest_revision, + source_revision, + source_fingerprint, + diagnostics.clone(), + ) + .expect("the current client attempt was validated before publication"); + } else { + completed_coordinator + .reject_client(newest_revision, diagnostics.clone()) + .expect("the current client attempt was validated before publication"); + } + + let client = publication.client.as_mut().expect("configured client host"); + match publish(client, candidate, newest_revision) { + Ok(client_publication) => { + publication.coordinator = completed_coordinator; + ClientPublicationAttempt::Completed(client_publication) + } + Err(error) => { + let message = error.to_string(); + let mut failure_diagnostics = diagnostics; + failure_diagnostics.push(format!("client publication failed: {message}")); + publication + .coordinator + .reject_client(newest_revision, failure_diagnostics.clone()) + .expect("prevalidated current attempt remains eligible after publication failure"); + ClientPublicationAttempt::Failed { + message, + diagnostics: failure_diagnostics, + serving_last_good: publication.coordinator.status().client.active.is_some(), + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ObservedLayout { + layout: ProjectLayout, + diagnostics: Vec, +} + +enum ClientObservation { + Captured(ProjectSourceSnapshot), + Invalid { + fingerprint: Fingerprint, + diagnostics: Vec, + }, +} + +impl ClientObservation { + fn fingerprint(&self) -> Fingerprint { + match self { + Self::Captured(snapshot) => client_source_fingerprint(snapshot), + Self::Invalid { fingerprint, .. } => fingerprint.clone(), + } + } + + fn snapshot(&self) -> Option<&ProjectSourceSnapshot> { + match self { + Self::Captured(snapshot) => Some(snapshot), + Self::Invalid { .. } => None, + } + } + + fn diagnostics(&self) -> Option<&[String]> { + match self { + Self::Captured(_) => None, + Self::Invalid { diagnostics, .. } => Some(diagnostics), + } + } +} + +struct ObservationFrame { + topology: Fingerprint, + backend: BackendObservation, + client: Option, + topology_diagnostics: Vec, +} + +fn capture_frame(layout: &ProjectLayout) -> Result { + for _ in 0..COHERENT_FRAME_ATTEMPTS { + let topology_before = topology_fingerprint(&layout.manifest_path); + let observed_layout_before = resolve_observed_layout(layout); + let backend_before = observe_backend(&observed_layout_before.layout); + let client = observed_layout_before + .layout + .client + .as_ref() + .map(|client| capture_observed_client(&observed_layout_before.layout, client)) + .transpose()?; + let observed_layout_after = resolve_observed_layout(layout); + let backend_after = observe_backend(&observed_layout_after.layout); + let topology_after = topology_fingerprint(&layout.manifest_path); + + // Re-parse and re-resolve the logical project on both sides of the + // subsystem captures. This observes safe in-project symlink retargets, + // prevents a cached canonical target from becoming a permanent watch + // root, and rejects a frame assembled across a topology transition. + if topology_before == topology_after + && observed_layout_before == observed_layout_after + && backend_before.fingerprint() == backend_after.fingerprint() + { + return Ok(ObservationFrame { + topology: topology_after, + backend: backend_after, + client, + topology_diagnostics: observed_layout_after.diagnostics, + }); + } + } + Err(format!( + "project inputs under {} did not remain unchanged across {COHERENT_FRAME_ATTEMPTS} coherent captures", + layout.root.display() + )) +} + +fn resolve_observed_layout(active: &ProjectLayout) -> ObservedLayout { + match spock_project::load_project_from(&active.root) { + Ok(layout) => ObservedLayout { + layout, + diagnostics: Vec::new(), + }, + Err(diagnostics) => ObservedLayout { + layout: active.clone(), + diagnostics: diagnostics.iter().map(ToString::to_string).collect(), + }, + } +} + +fn capture_observed_client( + layout: &ProjectLayout, + client: &spock_project::ClientLayout, +) -> Result { + match spock_project::resolve_contained(&layout.root, client.root.relative()) { + Ok(root) => capture_stable_client(root.absolute()).map(ClientObservation::Captured), + Err(diagnostics) => { + let diagnostics = diagnostics + .iter() + .map(ToString::to_string) + .collect::>(); + Ok(ClientObservation::Invalid { + fingerprint: invalid_client_fingerprint(&diagnostics), + diagnostics, + }) + } + } +} + +fn invalid_client_fingerprint(diagnostics: &[String]) -> Fingerprint { + let mut hasher = Sha256::new(); + hasher.update(b"spock-invalid-client-observation/1\0"); + hasher.update((diagnostics.len() as u64).to_be_bytes()); + for diagnostic in diagnostics { + hasher.update((diagnostic.len() as u64).to_be_bytes()); + hasher.update(diagnostic.as_bytes()); + } + Fingerprint::new(format!("{:x}", hasher.finalize())) +} + +fn apply_frame( + session: &crate::FrameworkSession, + active_backend: &BackendObservation, + active_topology: &Fingerprint, + frame: &ObservationFrame, + notices: &HostNoticeSink, +) -> ObservationDisposition { + let mut changed_inputs = frame.backend.changed_inputs_since(active_backend); + if &frame.topology != active_topology { + changed_inputs.push("spock.toml".to_string()); + changed_inputs.sort(); + changed_inputs.dedup(); + } + let mut backend_diagnostics = frame + .backend + .diagnostics() + .iter() + .map(ToString::to_string) + .collect::>(); + let client_diagnostics = frame + .client + .as_ref() + .and_then(ClientObservation::diagnostics) + .unwrap_or_default(); + backend_diagnostics.extend( + frame + .topology_diagnostics + .iter() + .filter(|diagnostic| { + !client_diagnostics + .iter() + .any(|client_diagnostic| client_diagnostic == *diagnostic) + }) + .cloned(), + ); + backend_diagnostics.sort(); + backend_diagnostics.dedup(); + let client = frame.client.as_ref().map(ClientObservation::fingerprint); + + let (before, disposition, after, superseded_client) = { + let publication_state = session.publication(); + let mut publication = publication_state.write().expect("project publication lock"); + let before_status = publication.coordinator.status(); + let before = before_status.backend.freshness; + let disposition = publication.coordinator.observe(Observation { + topology: frame.topology.clone(), + backend: frame.backend.fingerprint().clone(), + client, + changed_backend_inputs: changed_inputs, + backend_diagnostics: backend_diagnostics.clone(), + }); + let after_status = publication.coordinator.status(); + let after = after_status.backend.freshness; + let superseded_client = match ( + before_status.client.latest_attempt, + after_status.client.latest_attempt.as_ref(), + ) { + (Some(before_attempt), Some(after_attempt)) + if before_attempt.state == ClientAttemptState::Building + && after_attempt.state == ClientAttemptState::Rejected + && before_attempt.observed_revision == after_attempt.observed_revision => + { + Some(( + after_attempt.observed_revision, + after_attempt.diagnostics.clone(), + after_status.client.active.is_some(), + )) + } + _ => None, + }; + (before, disposition, after, superseded_client) + }; + + if !matches!(disposition, ObservationDisposition::NoChange) { + session.events().publish(); + } + match (before, after) { + (BackendFreshness::Active, BackendFreshness::RestartRequired) => { + let status = session.status(); + notices.emit(HostNotice::BackendRestartRequired { + changed_inputs: status.backend.changed_inputs, + diagnostics: backend_diagnostics, + }); + } + (BackendFreshness::RestartRequired, BackendFreshness::Active) => { + notices.emit(HostNotice::BackendReverted); + } + _ => {} + } + if let Some((observed_revision, diagnostics, serving_last_good)) = superseded_client { + notices.emit(HostNotice::ClientRejected { + observed_revision: observed_revision.get(), + diagnostics, + serving_last_good, + }); + } + disposition +} + +fn prepared_client_diagnostics(candidate: &crate::PreparedClient) -> Vec { + let diagnostics = candidate.diagnostics(); + [diagnostics.editor, diagnostics.play] + .into_iter() + .filter(|value| !value.is_null()) + .map(|value| serde_json::to_string(value).unwrap_or_else(|_| value.to_string())) + .collect() +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + use std::sync::Arc; + use std::time::Instant; + + use axum::routing::get; + use axum::Router; + use spock_project::{ + load_project_from, minimal_uhura_client_template, scaffold_plan, ProjectManifest, + }; + use tempfile::tempdir; + + use super::*; + use crate::{ClientAttemptState, ClientFreshness, ProjectStatus}; + + const NETWORK_TEST_DEADLINE: Duration = Duration::from_secs(7); + const NETWORK_TEST_POLL: Duration = Duration::from_millis(25); + + fn backend_project(root: &Path) -> ProjectLayout { + fs::create_dir(root.join("backend")).unwrap(); + fs::write(root.join("backend/app.spock"), "").unwrap(); + fs::write( + root.join("spock.toml"), + ProjectManifest::new("demo", "backend", "app.spock", None) + .unwrap() + .to_toml_string(), + ) + .unwrap(); + load_project_from(root).unwrap() + } + + fn full_stack_project(root: &Path) -> ProjectLayout { + let template = minimal_uhura_client_template(); + let plan = scaffold_plan(root, "demo", Some(&template)).unwrap(); + for write in plan.writes() { + let path = root.join(write.relative_path.as_path()); + fs::create_dir_all(path.parent().expect("scaffold file parent")).unwrap(); + fs::write(path, &write.contents).unwrap(); + } + load_project_from(root).unwrap() + } + + fn write_client_template(root: &Path) { + for file in minimal_uhura_client_template().files() { + let path = root.join(file.path().as_path()); + fs::create_dir_all(path.parent().expect("client file parent")).unwrap(); + fs::write(path, file.contents()).unwrap(); + } + } + + #[cfg(unix)] + fn replace_symlink(link: &Path, target: &Path) { + use std::os::unix::fs::symlink; + + let replacement = link.with_extension("next-link"); + let _ = fs::remove_file(&replacement); + symlink(target, &replacement).unwrap(); + fs::rename(replacement, link).unwrap(); + } + + fn dummy_uhura_assets(root: &Path) -> UhuraAssetRoots { + let web = root.join("web"); + let wasm = root.join("wasm"); + fs::create_dir_all(web.join("assets")).unwrap(); + fs::create_dir_all(&wasm).unwrap(); + fs::write( + web.join("index.html"), + r#""#, + ) + .unwrap(); + fs::write(web.join("assets/app.js"), "export {};\n").unwrap(); + fs::write(wasm.join("uhura_wasm.js"), "export {};\n").unwrap(); + fs::write(wasm.join("uhura_wasm_bg.wasm"), b"wasm").unwrap(); + UhuraAssetRoots { web, wasm } + } + + async fn status_until( + client: &reqwest::Client, + address: SocketAddr, + deadline: Instant, + description: &str, + predicate: impl Fn(&ProjectStatus) -> bool, + ) -> ProjectStatus { + let url = format!("http://{address}/~project/status"); + loop { + let last_observation = match client.get(&url).send().await { + Ok(response) if response.status() == reqwest::StatusCode::OK => { + match response.json::().await { + Ok(status) => { + if predicate(&status) { + return status; + } + format!("{status:#?}") + } + Err(error) => format!("invalid status JSON: {error}"), + } + } + Ok(response) => format!("status endpoint returned {}", response.status()), + Err(error) => error.to_string(), + }; + assert!( + Instant::now() < deadline, + "timed out waiting for {description}; last observation: {last_observation}" + ); + tokio::time::sleep(NETWORK_TEST_POLL).await; + } + } + + async fn ok_bytes(client: &reqwest::Client, address: SocketAddr, path: &str) -> Vec { + let response = client + .get(format!("http://{address}{path}")) + .send() + .await + .unwrap_or_else(|error| panic!("GET {path} failed: {error}")); + assert_eq!(response.status(), reqwest::StatusCode::OK, "GET {path}"); + response.bytes().await.unwrap().to_vec() + } + + #[test] + fn publication_failure_terminalizes_status_preserves_last_good_and_invalidates() { + let project = tempdir().unwrap(); + let assets = tempdir().unwrap(); + let layout = full_stack_project(project.path()); + let prepared = prepare_project( + layout, + HostMode::Dev, + None, + Some(dummy_uhura_assets(assets.path())), + ) + .unwrap(); + let session = Arc::clone(&prepared.session); + let initial_status = session.status(); + let initial_client = session + .publication() + .read() + .unwrap() + .client + .as_ref() + .unwrap() + .latest_publication() + .clone(); + let snapshot = + capture_stable_client(prepared.layout.client.as_ref().unwrap().root.absolute()) + .unwrap(); + let (revision, candidate) = { + let publication_state = session.publication(); + let mut publication = publication_state.write().unwrap(); + let status = publication.coordinator.status(); + publication.coordinator.observe(Observation { + topology: status.observed.topology_fingerprint, + backend: status.observed.backend_fingerprint, + client: Some(Fingerprint::new("injected-client-change")), + changed_backend_inputs: Vec::new(), + backend_diagnostics: Vec::new(), + }); + let revision = publication.coordinator.observed_revision(); + publication + .coordinator + .begin_client_attempt(revision) + .unwrap(); + let candidate = publication + .client + .as_ref() + .unwrap() + .prepare(&snapshot, revision); + (revision, candidate) + }; + let event_before = session.events().current_id(); + + let outcome = publish_client_candidate_with( + &session, + candidate, + revision, + Vec::new(), + |_client, _candidate, _revision| { + Err(ClientHostError::Uhura( + "injected publication failure".to_owned(), + )) + }, + ); + + match outcome { + ClientPublicationAttempt::Failed { + message, + diagnostics, + serving_last_good, + } => { + assert!(message.contains("injected publication failure")); + assert!(diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("client publication failed"))); + assert!(serving_last_good); + } + ClientPublicationAttempt::Completed(_) => panic!("injected failure published"), + } + assert_eq!(session.events().current_id(), event_before + 1); + let status = session.status(); + assert_eq!(status.client.freshness, ClientFreshness::RejectedLastGood); + assert_eq!( + status.client.active.unwrap().generation_id, + initial_status.client.active.unwrap().generation_id + ); + assert_eq!( + status.client.latest_attempt.unwrap().state, + ClientAttemptState::Rejected + ); + assert_eq!( + session + .publication() + .read() + .unwrap() + .client + .as_ref() + .unwrap() + .latest_publication(), + &initial_client + ); + } + + #[test] + fn removing_client_topology_terminalizes_a_superseded_in_flight_build() { + let project = tempdir().unwrap(); + let assets = tempdir().unwrap(); + let layout = full_stack_project(project.path()); + let prepared = prepare_project( + layout, + HostMode::Dev, + None, + Some(dummy_uhura_assets(assets.path())), + ) + .unwrap(); + let session = Arc::clone(&prepared.session); + let initial_status = session.status(); + let initial_client = session + .publication() + .read() + .unwrap() + .client + .as_ref() + .unwrap() + .latest_publication() + .clone(); + + let page = project.path().join("client/app/home/page.uhura"); + let original = fs::read_to_string(&page).unwrap(); + let edited = original.replace("Your app is running.", "A client build is in flight."); + assert_ne!(edited, original); + fs::write(&page, edited).unwrap(); + let changed_frame = capture_frame(&prepared.layout).unwrap(); + let changed = apply_frame( + &session, + &prepared.active_backend, + &prepared.active_topology, + &changed_frame, + &HostNoticeSink::default(), + ); + let observed_revision = match changed { + ObservationDisposition::Changed { + revision, + client_changed: true, + .. + } => revision, + other => panic!("expected a changed client observation, got {other:?}"), + }; + let snapshot = changed_frame + .client + .as_ref() + .and_then(ClientObservation::snapshot) + .expect("changed client snapshot"); + let candidate = { + let publication_state = session.publication(); + let mut publication = publication_state.write().unwrap(); + publication + .coordinator + .begin_client_attempt(observed_revision) + .unwrap(); + publication + .client + .as_ref() + .unwrap() + .prepare(snapshot, observed_revision) + }; + session.events().publish(); + assert_eq!(candidate.observed_revision(), observed_revision); + assert_eq!(session.status().client.freshness, ClientFreshness::Building); + + fs::write( + project.path().join("spock.toml"), + ProjectManifest::new("demo", "backend", "app.spock", None) + .unwrap() + .to_toml_string(), + ) + .unwrap(); + let removed_frame = capture_frame(&prepared.layout).unwrap(); + assert!(removed_frame.client.is_none()); + let captured_notices = Arc::new(std::sync::Mutex::new(Vec::new())); + let notices_for_sink = Arc::clone(&captured_notices); + let notices = HostNoticeSink::new(move |notice| { + notices_for_sink.lock().unwrap().push(notice); + }); + let event_before = session.events().current_id(); + + let removed = apply_frame( + &session, + &prepared.active_backend, + &prepared.active_topology, + &removed_frame, + ¬ices, + ); + + assert!(matches!( + removed, + ObservationDisposition::Changed { + client_changed: true, + .. + } + )); + assert_eq!(session.events().current_id(), event_before + 1); + let status = session.status(); + assert_eq!(status.backend.freshness, BackendFreshness::RestartRequired); + assert_eq!(status.client.freshness, ClientFreshness::RejectedLastGood); + assert_eq!( + status.client.active.as_ref().unwrap().generation_id, + initial_status.client.active.as_ref().unwrap().generation_id + ); + let attempt = status.client.latest_attempt.as_ref().unwrap(); + assert_eq!(attempt.observed_revision, observed_revision); + assert_eq!(attempt.state, ClientAttemptState::Rejected); + assert!(attempt + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("superseded by newer project observation"))); + assert_eq!( + session + .publication() + .read() + .unwrap() + .client + .as_ref() + .unwrap() + .latest_publication(), + &initial_client + ); + assert!(captured_notices.lock().unwrap().iter().any(|notice| { + match notice { + HostNotice::ClientRejected { + observed_revision: rejected_revision, + diagnostics, + serving_last_good, + } => { + *rejected_revision == observed_revision.get() + && *serving_last_good + && diagnostics.iter().any(|diagnostic| { + diagnostic.contains("superseded by newer project observation") + }) + } + _ => false, + } + })); + } + + #[tokio::test] + async fn graceful_shutdown_waits_for_an_accepted_request_to_finish() { + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let handler_entered = Arc::clone(&entered); + let handler_release = Arc::clone(&release); + let router = Router::new().route( + "/slow", + get(move || { + let entered = Arc::clone(&handler_entered); + let release = Arc::clone(&handler_release); + async move { + entered.notify_one(); + release.notified().await; + "finished" + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let (shutdown_started_tx, shutdown_started_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(serve_router_until_shutdown( + listener, + router, + async move { + let _ = shutdown_rx.await; + }, + move || { + let _ = shutdown_started_tx.send(()); + }, + )); + let request = tokio::spawn(async move { + reqwest::get(format!("http://{address}/slow")) + .await + .unwrap() + .text() + .await + .unwrap() + }); + + tokio::time::timeout(Duration::from_secs(2), entered.notified()) + .await + .expect("slow request entered its handler"); + shutdown_tx.send(()).unwrap(); + shutdown_started_rx + .await + .expect("graceful-shutdown callback ran"); + assert!( + !server.is_finished(), + "server returned while an accepted request was still active" + ); + + release.notify_one(); + assert_eq!(request.await.unwrap(), "finished"); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("server drained the completed request") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn fixed_server_binds_ephemeral_port_and_releases_it_on_shutdown() { + let temp = tempdir().unwrap(); + let layout = backend_project(temp.path()); + let options = ServeOptions { + bind: "127.0.0.1:0".parse().unwrap(), + ..ServeOptions::default() + }; + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let ready_tx = std::sync::Mutex::new(Some(ready_tx)); + let notices = HostNoticeSink::new(move |notice| { + if let HostNotice::Listening { address, .. } = notice { + if let Some(sender) = ready_tx.lock().unwrap().take() { + let _ = sender.send(address); + } + } + }); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(serve_project( + layout, + HostMode::Start, + options, + notices, + async move { + let _ = shutdown_rx.await; + }, + )); + let address = ready_rx.await.unwrap(); + let client = reqwest::Client::new(); + let health = client + .get(format!("http://{address}/~health")) + .send() + .await + .unwrap(); + assert_eq!(health.status(), reqwest::StatusCode::OK); + let events = client + .get(format!("http://{address}/~project/events")) + .send() + .await + .expect("open project event stream"); + assert_eq!(events.status(), reqwest::StatusCode::OK); + + shutdown_tx.send(()).unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("open SSE stream did not prevent graceful shutdown") + .unwrap() + .unwrap() + .local_address, + address + ); + drop(events); + let rebound = tokio::net::TcpListener::bind(address).await.unwrap(); + drop(rebound); + } + + #[tokio::test] + async fn observer_panic_is_reported_only_after_server_and_backend_shutdown() { + let temp = tempdir().unwrap(); + let layout = backend_project(temp.path()); + let options = ServeOptions { + bind: "127.0.0.1:0".parse().unwrap(), + poll_interval: NETWORK_TEST_POLL, + ..ServeOptions::default() + }; + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let ready_tx = std::sync::Mutex::new(Some(ready_tx)); + let observer_panicked = Arc::new(AtomicBool::new(false)); + let observer_panicked_for_notice = Arc::clone(&observer_panicked); + let notices = HostNoticeSink::new(move |notice| match notice { + HostNotice::Listening { address, .. } => { + if let Some(sender) = ready_tx.lock().unwrap().take() { + let _ = sender.send(address); + } + } + HostNotice::BackendRestartRequired { .. } => { + observer_panicked_for_notice.store(true, Ordering::Release); + panic!("injected observer panic"); + } + _ => {} + }); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(serve_project( + layout, + HostMode::Dev, + options, + notices, + async move { + let _ = shutdown_rx.await; + }, + )); + let address = ready_rx.await.unwrap(); + fs::write( + temp.path().join("backend/app.spock"), + "// make the backend restart-required\n", + ) + .unwrap(); + tokio::time::timeout(Duration::from_secs(2), async { + while !observer_panicked.load(Ordering::Acquire) { + tokio::time::sleep(NETWORK_TEST_POLL).await; + } + }) + .await + .expect("observer did not reach injected panic"); + + shutdown_tx.send(()).unwrap(); + let result = tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("server did not shut down after observer panic") + .expect("serve task itself must not panic"); + assert!(matches!(result, Err(ServeError::Observer(_)))); + let rebound = tokio::net::TcpListener::bind(address).await.unwrap(); + drop(rebound); + } + + #[tokio::test] + async fn dev_server_keeps_one_port_and_last_good_generations_across_source_changes() { + let project = tempdir().unwrap(); + let assets = tempdir().unwrap(); + let layout = full_stack_project(project.path()); + let options = ServeOptions { + bind: "127.0.0.1:0".parse().unwrap(), + asset_roots: Some(dummy_uhura_assets(assets.path())), + poll_interval: NETWORK_TEST_POLL, + ..ServeOptions::default() + }; + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let ready_tx = std::sync::Mutex::new(Some(ready_tx)); + let notices = HostNoticeSink::new(move |notice| { + if let HostNotice::Listening { address, .. } = notice { + if let Some(sender) = ready_tx.lock().unwrap().take() { + let _ = sender.send(address); + } + } + }); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(serve_project( + layout, + HostMode::Dev, + options, + notices, + async move { + let _ = shutdown_rx.await; + }, + )); + let address = ready_rx.await.unwrap(); + let client = reqwest::Client::new(); + let deadline = Instant::now() + NETWORK_TEST_DEADLINE; + + let initial = status_until( + &client, + address, + deadline, + "the initial full-stack generation", + |status| { + status.backend.freshness == BackendFreshness::Active + && status.client.freshness == ClientFreshness::Active + }, + ) + .await; + let backend_generation = initial.backend.generation_id; + let initial_observed_revision = initial.observed.revision; + + for path in [ + "/", + "/play", + "/api/editor/state", + "/api/play/ir.json", + "/~contract", + "/~project/status", + "/~health", + ] { + let _ = ok_bytes(&client, address, path).await; + } + let initial_play = ok_bytes(&client, address, "/api/play/ir.json").await; + let initial_contract = ok_bytes(&client, address, "/~contract").await; + + let client_source = project.path().join("client/app/home/page.uhura"); + let original_client_source = fs::read(&client_source).unwrap(); + fs::write(&client_source, "this is not valid uhura\n").unwrap(); + let rejected = status_until( + &client, + address, + deadline, + "a rejected client candidate with the last good Play generation", + |status| status.client.freshness == ClientFreshness::RejectedLastGood, + ) + .await; + assert!(rejected.observed.revision > initial_observed_revision); + assert_eq!(rejected.backend.generation_id, backend_generation); + assert_eq!( + ok_bytes(&client, address, "/api/play/ir.json").await, + initial_play, + "a rejected edit must not replace the last good Play artifact" + ); + + fs::write(&client_source, original_client_source).unwrap(); + let restored = status_until( + &client, + address, + deadline, + "a restored active client generation", + |status| { + status.client.freshness == ClientFreshness::Active + && status.observed.revision > rejected.observed.revision + }, + ) + .await; + assert_eq!(restored.backend.generation_id, backend_generation); + assert_eq!( + restored + .client + .active + .as_ref() + .expect("restored active client") + .observed_revision, + restored.observed.revision + ); + + let backend_source = project.path().join("backend/app.spock"); + let original_backend_source = fs::read(&backend_source).unwrap(); + let mut changed_backend_source = original_backend_source.clone(); + changed_backend_source.extend_from_slice(b"// requires a restart\n"); + fs::write(&backend_source, changed_backend_source).unwrap(); + let restart_required = status_until( + &client, + address, + deadline, + "a backend restart-required observation", + |status| status.backend.freshness == BackendFreshness::RestartRequired, + ) + .await; + assert_eq!(restart_required.backend.generation_id, backend_generation); + assert_eq!( + restart_required.active_project.backend_generation_id, + backend_generation + ); + assert_eq!( + ok_bytes(&client, address, "/~contract").await, + initial_contract, + "backend observation must not replace the active generation" + ); + + fs::write(&backend_source, original_backend_source).unwrap(); + let reverted = status_until( + &client, + address, + deadline, + "the exact backend reversion", + |status| status.backend.freshness == BackendFreshness::Active, + ) + .await; + assert_eq!(reverted.backend.generation_id, backend_generation); + assert_eq!( + ok_bytes(&client, address, "/~contract").await, + initial_contract + ); + + shutdown_tx.send(()).unwrap(); + drop(client); + let outcome = tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("framework host shutdown timed out") + .unwrap() + .unwrap(); + assert_eq!(outcome.local_address, address); + let rebound = tokio::net::TcpListener::bind(address).await.unwrap(); + drop(rebound); + } + + #[cfg(unix)] + #[tokio::test] + async fn dev_observes_client_root_retargets_and_rejects_escapes_without_backend_swap() { + use std::os::unix::fs::symlink; + + let project = tempdir().unwrap(); + let assets = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::create_dir(project.path().join("backend")).unwrap(); + fs::write(project.path().join("backend/app.spock"), "").unwrap(); + write_client_template(&project.path().join("client-a")); + write_client_template(&project.path().join("client-b")); + let retargeted_page = project.path().join("client-b/app/home/page.uhura"); + let retargeted_source = fs::read_to_string(&retargeted_page) + .unwrap() + .replace("Your app is running.", "The retargeted app is running."); + fs::write(&retargeted_page, retargeted_source).unwrap(); + write_client_template(outside.path()); + symlink("client-a", project.path().join("client")).unwrap(); + fs::write( + project.path().join("spock.toml"), + ProjectManifest::new("demo", "backend", "app.spock", Some("client")) + .unwrap() + .to_toml_string(), + ) + .unwrap(); + let layout = load_project_from(project.path()).unwrap(); + let options = ServeOptions { + bind: "127.0.0.1:0".parse().unwrap(), + asset_roots: Some(dummy_uhura_assets(assets.path())), + poll_interval: NETWORK_TEST_POLL, + ..ServeOptions::default() + }; + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel(); + let ready_tx = std::sync::Mutex::new(Some(ready_tx)); + let notices = HostNoticeSink::new(move |notice| { + if let HostNotice::Listening { address, .. } = notice { + if let Some(sender) = ready_tx.lock().unwrap().take() { + let _ = sender.send(address); + } + } + }); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(serve_project( + layout, + HostMode::Dev, + options, + notices, + async move { + let _ = shutdown_rx.await; + }, + )); + let address = ready_rx.await.unwrap(); + let client = reqwest::Client::new(); + let initial = status_until( + &client, + address, + Instant::now() + NETWORK_TEST_DEADLINE, + "initial symlinked client generation", + |status| status.client.freshness == ClientFreshness::Active, + ) + .await; + let backend_generation = initial.backend.generation_id; + let initial_play = ok_bytes(&client, address, "/api/play/ir.json").await; + + replace_symlink(&project.path().join("client"), outside.path()); + let rejected = status_until( + &client, + address, + Instant::now() + NETWORK_TEST_DEADLINE, + "escaping client-root observation", + |status| { + status.backend.generation_id == backend_generation + && status.backend.freshness == BackendFreshness::Active + && status.backend.diagnostics.is_empty() + && status.client.freshness == ClientFreshness::RejectedLastGood + && status + .client + .latest_attempt + .as_ref() + .is_some_and(|attempt| { + attempt + .diagnostics + .iter() + .any(|diagnostic| diagnostic.contains("SPP011")) + }) + }, + ) + .await; + assert_eq!( + ok_bytes(&client, address, "/api/play/ir.json").await, + initial_play, + "an escaping retarget must retain the last-good client" + ); + + replace_symlink(&project.path().join("client"), Path::new("client-b")); + let recovered = status_until( + &client, + address, + Instant::now() + NETWORK_TEST_DEADLINE, + "safe client-root retarget publication", + |status| { + status.backend.generation_id == backend_generation + && status.backend.freshness == BackendFreshness::Active + && status.client.freshness == ClientFreshness::Active + && status.observed.revision > rejected.observed.revision + }, + ) + .await; + assert_eq!(recovered.backend.generation_id, backend_generation); + assert_ne!( + ok_bytes(&client, address, "/api/play/ir.json").await, + initial_play, + "safe retarget must publish the newly resolved client tree" + ); + + shutdown_tx.send(()).unwrap(); + drop(client); + tokio::time::timeout(Duration::from_secs(2), task) + .await + .expect("symlinked dev host shutdown timed out") + .unwrap() + .unwrap(); + } + + #[cfg(unix)] + #[test] + fn coherent_frames_re_resolve_backend_root_and_entry_symlinks_and_reject_escapes() { + use std::os::unix::fs::symlink; + + let project = tempdir().unwrap(); + let outside = tempdir().unwrap(); + for directory in ["backend-a", "backend-b"] { + fs::create_dir(project.path().join(directory)).unwrap(); + } + fs::write( + project.path().join("backend-a/source-a.spock"), + "// active backend\n", + ) + .unwrap(); + fs::write( + project.path().join("backend-b/source-b.spock"), + "table beta { key id: uuid = auto }\n", + ) + .unwrap(); + fs::write( + project.path().join("backend-b/source-c.spock"), + "table gamma { key id: uuid = auto }\n", + ) + .unwrap(); + symlink("source-a.spock", project.path().join("backend-a/app.spock")).unwrap(); + symlink("source-b.spock", project.path().join("backend-b/app.spock")).unwrap(); + symlink("backend-a", project.path().join("backend")).unwrap(); + fs::write( + project.path().join("spock.toml"), + ProjectManifest::new("demo", "backend", "app.spock", None) + .unwrap() + .to_toml_string(), + ) + .unwrap(); + let layout = load_project_from(project.path()).unwrap(); + let prepared = prepare_project(layout, HostMode::Dev, None, None).unwrap(); + let backend_generation = prepared.session.status().backend.generation_id; + assert!(prepared.session.backend().contract().tables.is_empty()); + + replace_symlink(&project.path().join("backend"), Path::new("backend-b")); + let root_retarget = capture_frame(&prepared.layout).unwrap(); + assert_eq!( + root_retarget + .backend + .captured_backend() + .expect("safe backend-root retarget") + .source(), + b"table beta { key id: uuid = auto }\n" + ); + let first_observed = root_retarget.backend.fingerprint().clone(); + apply_frame( + &prepared.session, + &prepared.active_backend, + &prepared.active_topology, + &root_retarget, + &HostNoticeSink::default(), + ); + assert_eq!( + prepared.session.status().backend.freshness, + BackendFreshness::RestartRequired + ); + assert_eq!( + prepared.session.status().backend.generation_id, + backend_generation + ); + assert!(prepared.session.backend().contract().tables.is_empty()); + + replace_symlink( + &project.path().join("backend-b/app.spock"), + Path::new("source-c.spock"), + ); + let entry_retarget = capture_frame(&prepared.layout).unwrap(); + assert_eq!( + entry_retarget + .backend + .captured_backend() + .expect("safe backend-entry retarget") + .source(), + b"table gamma { key id: uuid = auto }\n" + ); + assert_ne!(entry_retarget.backend.fingerprint(), &first_observed); + apply_frame( + &prepared.session, + &prepared.active_backend, + &prepared.active_topology, + &entry_retarget, + &HostNoticeSink::default(), + ); + assert_eq!( + prepared.session.status().backend.generation_id, + backend_generation + ); + assert!(prepared.session.backend().contract().tables.is_empty()); + + fs::write(outside.path().join("app.spock"), "table escaped {}\n").unwrap(); + replace_symlink(&project.path().join("backend"), outside.path()); + let escaped = capture_frame(&prepared.layout).unwrap(); + assert!(!escaped.backend.is_valid()); + apply_frame( + &prepared.session, + &prepared.active_backend, + &prepared.active_topology, + &escaped, + &HostNoticeSink::default(), + ); + let status = prepared.session.status(); + assert_eq!(status.backend.generation_id, backend_generation); + assert_eq!(status.backend.freshness, BackendFreshness::RestartRequired); + assert!(status + .backend + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.contains("SPH004") || diagnostic.contains("SPP011") })); + assert!(prepared.session.backend().contract().tables.is_empty()); + + fs::write( + project.path().join("spock.toml"), + ProjectManifest::new("demo", "backend-b", "app.spock", None) + .unwrap() + .to_toml_string(), + ) + .unwrap(); + let manifest_retarget = capture_frame(&prepared.layout).unwrap(); + assert_eq!( + manifest_retarget + .backend + .captured_backend() + .expect("valid manifest path retarget") + .source(), + b"table gamma { key id: uuid = auto }\n" + ); + assert_ne!(manifest_retarget.topology, prepared.active_topology); + apply_frame( + &prepared.session, + &prepared.active_backend, + &prepared.active_topology, + &manifest_retarget, + &HostNoticeSink::default(), + ); + let status = prepared.session.status(); + assert_eq!(status.backend.generation_id, backend_generation); + assert_eq!(status.backend.freshness, BackendFreshness::RestartRequired); + assert!(status.backend.diagnostics.is_empty()); + assert!(prepared.session.backend().contract().tables.is_empty()); + } + + #[test] + fn backend_observation_marks_restart_and_exact_reversion_without_runtime_work() { + let temp = tempdir().unwrap(); + let layout = backend_project(temp.path()); + let prepared = prepare_project(layout, HostMode::Dev, None, None).unwrap(); + let notices = HostNoticeSink::default(); + + fs::write( + prepared.layout.root.join("backend/app.spock"), + "// changed\n", + ) + .unwrap(); + let changed = capture_frame(&prepared.layout).unwrap(); + apply_frame( + &prepared.session, + &prepared.active_backend, + &prepared.active_topology, + &changed, + ¬ices, + ); + assert_eq!( + prepared.session.status().backend.freshness, + BackendFreshness::RestartRequired + ); + + fs::write(prepared.layout.root.join("backend/app.spock"), "").unwrap(); + let reverted = capture_frame(&prepared.layout).unwrap(); + apply_frame( + &prepared.session, + &prepared.active_backend, + &prepared.active_topology, + &reverted, + ¬ices, + ); + assert_eq!( + prepared.session.status().backend.freshness, + BackendFreshness::Active + ); + assert_eq!( + prepared + .session + .backend() + .input_fingerprint() + .unwrap() + .as_str(), + prepared.active_backend.fingerprint().as_str() + ); + } +} diff --git a/crates/spock-project/.gitignore b/crates/spock-project/.gitignore new file mode 100644 index 0000000..042776a --- /dev/null +++ b/crates/spock-project/.gitignore @@ -0,0 +1,2 @@ +/Cargo.lock +/target/ diff --git a/crates/spock-project/Cargo.toml b/crates/spock-project/Cargo.toml new file mode 100644 index 0000000..0f55aaf --- /dev/null +++ b/crates/spock-project/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "spock-project" +description = "Project manifests, discovery, contained paths, and creation plans for Spock" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +toml.workspace = true +unicode-normalization.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/spock-project/src/diagnostic.rs b/crates/spock-project/src/diagnostic.rs new file mode 100644 index 0000000..82293ed --- /dev/null +++ b/crates/spock-project/src/diagnostic.rs @@ -0,0 +1,191 @@ +use std::fmt; +use std::ops::Range; +use std::path::PathBuf; + +/// Stable diagnostic categories emitted by the project layer. +/// +/// The human text may become more helpful over time; callers should branch on +/// this code rather than parsing that text. +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub enum DiagnosticCode { + TomlSyntax, + MissingField, + UnknownField, + WrongType, + UnsupportedVersion, + InvalidProjectName, + InvalidManifestPath, + Io, + ProjectNotFound, + UnsupportedTarget, + PathEscape, + MissingInput, + WrongEntryKind, + AlreadyProject, + AmbiguousBackend, + AmbiguousClient, + PlanConflict, + UnsafeSymlink, + InvalidTemplate, +} + +impl DiagnosticCode { + pub const fn as_str(self) -> &'static str { + match self { + Self::TomlSyntax => "SPP001", + Self::MissingField => "SPP002", + Self::UnknownField => "SPP003", + Self::WrongType => "SPP004", + Self::UnsupportedVersion => "SPP005", + Self::InvalidProjectName => "SPP006", + Self::InvalidManifestPath => "SPP007", + Self::Io => "SPP008", + Self::ProjectNotFound => "SPP009", + Self::UnsupportedTarget => "SPP010", + Self::PathEscape => "SPP011", + Self::MissingInput => "SPP012", + Self::WrongEntryKind => "SPP013", + Self::AlreadyProject => "SPP014", + Self::AmbiguousBackend => "SPP015", + Self::AmbiguousClient => "SPP016", + Self::PlanConflict => "SPP017", + Self::UnsafeSymlink => "SPP018", + Self::InvalidTemplate => "SPP019", + } + } +} + +impl fmt::Display for DiagnosticCode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +/// One structured project diagnostic. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Diagnostic { + pub code: DiagnosticCode, + pub message: String, + pub path: Option, + pub span: Option>, + pub notes: Vec, +} + +impl Diagnostic { + pub fn new(code: DiagnosticCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + path: None, + span: None, + notes: Vec::new(), + } + } + + pub fn at_path(mut self, path: impl Into) -> Self { + self.path = Some(path.into()); + self + } + + pub fn with_span(mut self, span: Range) -> Self { + self.span = Some(span); + self + } + + pub fn with_note(mut self, note: impl Into) -> Self { + self.notes.push(note.into()); + self + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{}: ", self.code)?; + if let Some(path) = &self.path { + write!(formatter, "{}: ", path.display())?; + } + formatter.write_str(&self.message)?; + for note in &self.notes { + write!(formatter, "\n note: {note}")?; + } + Ok(()) + } +} + +/// A deterministic collection of diagnostics from one operation. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Diagnostics(Vec); + +impl Diagnostics { + pub fn new() -> Self { + Self::default() + } + + pub fn one(diagnostic: Diagnostic) -> Self { + Self(vec![diagnostic]) + } + + pub fn push(&mut self, diagnostic: Diagnostic) { + self.0.push(diagnostic); + } + + pub fn extend(&mut self, diagnostics: impl IntoIterator) { + self.0.extend(diagnostics); + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn len(&self) -> usize { + self.0.len() + } + + pub fn iter(&self) -> impl ExactSizeIterator { + self.0.iter() + } + + pub fn into_vec(self) -> Vec { + self.0 + } +} + +impl From for Diagnostics { + fn from(diagnostic: Diagnostic) -> Self { + Self::one(diagnostic) + } +} + +impl IntoIterator for Diagnostics { + type Item = Diagnostic; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +impl<'a> IntoIterator for &'a Diagnostics { + type Item = &'a Diagnostic; + type IntoIter = std::slice::Iter<'a, Diagnostic>; + + fn into_iter(self) -> Self::IntoIter { + self.0.iter() + } +} + +impl fmt::Display for Diagnostics { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for (index, diagnostic) in self.0.iter().enumerate() { + if index != 0 { + formatter.write_str("\n")?; + } + diagnostic.fmt(formatter)?; + } + Ok(()) + } +} + +impl std::error::Error for Diagnostics {} + +pub type ProjectResult = Result; diff --git a/crates/spock-project/src/discovery.rs b/crates/spock-project/src/discovery.rs new file mode 100644 index 0000000..0cd26d6 --- /dev/null +++ b/crates/spock-project/src/discovery.rs @@ -0,0 +1,222 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, ProjectResult}; +use crate::manifest::MANIFEST_FILE; +use crate::path::{absolute_target, canonical_directory}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectRoot { + root: PathBuf, +} + +impl ProjectRoot { + pub fn path(&self) -> &Path { + &self.root + } + + pub fn manifest_path(&self) -> PathBuf { + self.root.join(MANIFEST_FILE) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResolvedTarget { + SpockFile(PathBuf), + Project(ProjectRoot), +} + +/// Find the nearest `spock.toml`, starting at a directory (or the parent of an +/// existing file) and walking toward the filesystem root. +pub fn discover_project_root(start: &Path) -> ProjectResult { + let start_directory = if start.is_file() { + start.parent().unwrap_or(start) + } else { + start + }; + let canonical = canonical_directory(start_directory)?; + let mut searched = Vec::new(); + for ancestor in canonical.ancestors() { + searched.push(ancestor.to_path_buf()); + if fs::symlink_metadata(ancestor.join(MANIFEST_FILE)).is_ok() { + return Ok(ProjectRoot { + root: ancestor.to_path_buf(), + }); + } + } + + let mut diagnostic = Diagnostic::new( + DiagnosticCode::ProjectNotFound, + format!( + "could not find `{MANIFEST_FILE}` from {} or any parent directory", + canonical.display() + ), + ) + .at_path(canonical); + for directory in searched { + diagnostic = diagnostic.with_note(format!("searched {}", directory.display())); + } + Err(diagnostic.into()) +} + +/// Resolve the CLI's polymorphic target without reading or interpreting either +/// source language. +/// +/// An explicit `.spock` spelling always selects file mode, even when the file +/// does not exist yet. An omitted target or directory selects the nearest +/// enclosing project. An explicit `spock.toml` selects exactly its parent. +pub fn resolve_target(target: Option<&Path>, cwd: &Path) -> ProjectResult { + let canonical_cwd = canonical_directory(cwd)?; + let Some(target) = target else { + return discover_project_root(&canonical_cwd).map(ResolvedTarget::Project); + }; + + if target.extension().and_then(|extension| extension.to_str()) == Some("spock") { + return absolute_target(&canonical_cwd, target).map(ResolvedTarget::SpockFile); + } + + if target.file_name().and_then(|name| name.to_str()) == Some(MANIFEST_FILE) { + // Resolve the parent, not the manifest itself. This deliberately keeps + // a final manifest symlink visible so `load_project` can reject it + // instead of silently changing the selected project root. + let requested_parent = target.parent().unwrap_or_else(|| Path::new(".")); + let parent = absolute_target(&canonical_cwd, requested_parent)?; + let absolute = parent.join(MANIFEST_FILE); + let metadata = fs::symlink_metadata(&absolute).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::MissingInput, + format!("could not read explicit project manifest: {error}"), + ) + .at_path(&absolute), + ) + })?; + if metadata.file_type().is_dir() { + return Err(Diagnostic::new( + DiagnosticCode::WrongEntryKind, + "explicit project manifest is a directory", + ) + .at_path(absolute) + .into()); + } + return Ok(ResolvedTarget::Project(ProjectRoot { root: parent })); + } + + let absolute = absolute_target(&canonical_cwd, target)?; + match fs::metadata(&absolute) { + Ok(metadata) if metadata.is_dir() => { + discover_project_root(&absolute).map(ResolvedTarget::Project) + } + Ok(_) => Err(Diagnostic::new( + DiagnosticCode::UnsupportedTarget, + format!( + "target is neither a `.spock` file, `{MANIFEST_FILE}`, nor a project directory" + ), + ) + .at_path(absolute) + .into()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Err(Diagnostic::new( + DiagnosticCode::UnsupportedTarget, + "target does not exist and is not a `.spock` file", + ) + .at_path(absolute) + .into()), + Err(error) => Err(Diagnostic::new( + DiagnosticCode::Io, + format!("could not inspect target: {error}"), + ) + .at_path(absolute) + .into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn nearest_manifest_wins_at_nested_project_boundaries() { + let temp = tempdir().unwrap(); + fs::write(temp.path().join(MANIFEST_FILE), "").unwrap(); + let nested = temp.path().join("a/nested"); + fs::create_dir_all(nested.join("src/deep")).unwrap(); + fs::write(nested.join(MANIFEST_FILE), "").unwrap(); + + let root = discover_project_root(&nested.join("src/deep")).unwrap(); + assert_eq!(root.path(), fs::canonicalize(nested).unwrap()); + } + + #[test] + fn omitted_directory_manifest_and_file_targets_are_unambiguous() { + let temp = tempdir().unwrap(); + fs::write(temp.path().join(MANIFEST_FILE), "").unwrap(); + fs::create_dir(temp.path().join("child")).unwrap(); + + assert!(matches!( + resolve_target(None, &temp.path().join("child")).unwrap(), + ResolvedTarget::Project(_) + )); + assert!(matches!( + resolve_target(Some(Path::new("child")), temp.path()).unwrap(), + ResolvedTarget::Project(_) + )); + assert!(matches!( + resolve_target(Some(Path::new(MANIFEST_FILE)), temp.path()).unwrap(), + ResolvedTarget::Project(_) + )); + let target = resolve_target(Some(Path::new("missing.spock")), temp.path()).unwrap(); + assert_eq!( + target, + ResolvedTarget::SpockFile(fs::canonicalize(temp.path()).unwrap().join("missing.spock")) + ); + } + + #[test] + fn project_not_found_reports_every_searched_directory() { + let temp = tempdir().unwrap(); + let nested = temp.path().join("a/b"); + fs::create_dir_all(&nested).unwrap(); + let diagnostic = discover_project_root(&nested) + .unwrap_err() + .into_vec() + .remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::ProjectNotFound); + assert!(diagnostic.notes.len() >= 3); + } + + #[test] + fn non_spock_files_and_missing_directories_are_not_guessed() { + let temp = tempdir().unwrap(); + fs::write(temp.path().join("notes.txt"), "notes").unwrap(); + for target in ["notes.txt", "missing"] { + let diagnostic = resolve_target(Some(Path::new(target)), temp.path()) + .unwrap_err() + .into_vec() + .remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::UnsupportedTarget); + } + } + + #[cfg(unix)] + #[test] + fn explicit_manifest_symlink_does_not_change_the_selected_root() { + use std::os::unix::fs::symlink; + + let project = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::write(outside.path().join(MANIFEST_FILE), "version = 1\n").unwrap(); + symlink( + outside.path().join(MANIFEST_FILE), + project.path().join(MANIFEST_FILE), + ) + .unwrap(); + + let target = resolve_target(Some(Path::new(MANIFEST_FILE)), project.path()).unwrap(); + let ResolvedTarget::Project(root) = target else { + panic!("explicit manifest did not select project mode"); + }; + assert_eq!(root.path(), fs::canonicalize(project.path()).unwrap()); + } +} diff --git a/crates/spock-project/src/layout.rs b/crates/spock-project/src/layout.rs new file mode 100644 index 0000000..7344e78 --- /dev/null +++ b/crates/spock-project/src/layout.rs @@ -0,0 +1,284 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, ProjectResult}; +use crate::discovery::{discover_project_root, ProjectRoot}; +use crate::manifest::{parse_manifest_file, ProjectManifest, MANIFEST_FILE}; +use crate::path::{resolve_contained, ContainedPath, NormalizedRelativePath}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientLayout { + pub root: ContainedPath, + pub manifest: ContainedPath, +} + +/// Validated filesystem topology for one project. This contains paths and the +/// framework manifest only; language hosts still capture and interpret their +/// own semantic inputs. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectLayout { + pub root: PathBuf, + pub manifest_path: PathBuf, + pub manifest: ProjectManifest, + pub backend_root: ContainedPath, + pub backend_entry: ContainedPath, + pub client: Option, +} + +pub fn load_project(root: &ProjectRoot) -> ProjectResult { + let manifest_path = root.manifest_path(); + let manifest_metadata = fs::symlink_metadata(&manifest_path).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::MissingInput, + format!("could not inspect `{MANIFEST_FILE}`: {error}"), + ) + .at_path(&manifest_path), + ) + })?; + if manifest_metadata.file_type().is_symlink() { + return Err(Diagnostic::new( + DiagnosticCode::UnsafeSymlink, + "the project manifest must be a regular file, not a symlink", + ) + .at_path(manifest_path) + .into()); + } + if !manifest_metadata.is_file() { + return Err(Diagnostic::new( + DiagnosticCode::WrongEntryKind, + "the project manifest is not a regular file", + ) + .at_path(manifest_path) + .into()); + } + + let source = fs::read_to_string(&manifest_path).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not read project manifest: {error}"), + ) + .at_path(&manifest_path), + ) + })?; + let manifest = parse_manifest_file(&source, &manifest_path)?; + + let backend_root = resolve_contained(root.path(), manifest.backend().root())?; + let backend_entry_relative = manifest.backend().root().join(manifest.backend().entry()); + let backend_entry = resolve_contained(root.path(), &backend_entry_relative)?; + + let mut diagnostics = Diagnostics::new(); + expect_directory( + backend_root.absolute(), + "configured backend root", + &mut diagnostics, + ); + expect_file( + backend_entry.absolute(), + "configured backend entry", + &mut diagnostics, + ); + + let client = if let Some(config) = manifest.client() { + let client_root = resolve_contained(root.path(), config.root())?; + let uhura_name = NormalizedRelativePath::file("uhura.toml") + .expect("constant Uhura manifest path is valid"); + let client_manifest_relative = config.root().join(&uhura_name); + let client_manifest = resolve_contained(root.path(), &client_manifest_relative)?; + expect_directory( + client_root.absolute(), + "configured client root", + &mut diagnostics, + ); + expect_file( + client_manifest.absolute(), + "configured client `uhura.toml`", + &mut diagnostics, + ); + Some(ClientLayout { + root: client_root, + manifest: client_manifest, + }) + } else { + None + }; + + if !diagnostics.is_empty() { + return Err(diagnostics); + } + + Ok(ProjectLayout { + root: root.path().to_path_buf(), + manifest_path, + manifest, + backend_root, + backend_entry, + client, + }) +} + +pub fn load_project_from(start: &Path) -> ProjectResult { + let root = discover_project_root(start)?; + load_project(&root) +} + +fn expect_directory(path: &Path, label: &str, diagnostics: &mut Diagnostics) { + match fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => diagnostics.push( + Diagnostic::new( + DiagnosticCode::WrongEntryKind, + format!("{label} is not a directory"), + ) + .at_path(path), + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => diagnostics.push( + Diagnostic::new( + DiagnosticCode::MissingInput, + format!("{label} does not exist"), + ) + .at_path(path), + ), + Err(error) => diagnostics.push( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not inspect {label}: {error}"), + ) + .at_path(path), + ), + } +} + +fn expect_file(path: &Path, label: &str, diagnostics: &mut Diagnostics) { + match fs::metadata(path) { + Ok(metadata) if metadata.is_file() => {} + Ok(_) => diagnostics.push( + Diagnostic::new( + DiagnosticCode::WrongEntryKind, + format!("{label} is not a regular file"), + ) + .at_path(path), + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => diagnostics.push( + Diagnostic::new( + DiagnosticCode::MissingInput, + format!("{label} does not exist"), + ) + .at_path(path), + ), + Err(error) => diagnostics.push( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not inspect {label}: {error}"), + ) + .at_path(path), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn write_manifest(root: &Path, client: bool) { + let manifest = + ProjectManifest::new("demo", "backend", "app.spock", client.then_some("client")) + .unwrap(); + fs::write(root.join(MANIFEST_FILE), manifest.to_toml_string()).unwrap(); + } + + #[test] + fn loads_validated_backend_and_optional_client_topology() { + let temp = tempdir().unwrap(); + fs::create_dir(temp.path().join("backend")).unwrap(); + fs::write(temp.path().join("backend/app.spock"), "").unwrap(); + fs::create_dir(temp.path().join("client")).unwrap(); + fs::write(temp.path().join("client/uhura.toml"), "").unwrap(); + write_manifest(temp.path(), true); + + let layout = load_project_from(temp.path()).unwrap(); + assert_eq!(layout.manifest.project().as_str(), "demo"); + assert_eq!( + layout.backend_entry.absolute(), + &fs::canonicalize(temp.path().join("backend/app.spock")).unwrap() + ); + assert!(layout.client.is_some()); + } + + #[test] + fn missing_backend_and_client_inputs_are_reported_together() { + let temp = tempdir().unwrap(); + fs::create_dir(temp.path().join("backend")).unwrap(); + fs::create_dir(temp.path().join("client")).unwrap(); + write_manifest(temp.path(), true); + + let diagnostics = load_project_from(temp.path()).unwrap_err(); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics + .iter() + .all(|diagnostic| diagnostic.code == DiagnosticCode::MissingInput)); + } + + #[cfg(unix)] + #[test] + fn rejects_a_symlinked_root_that_escapes_the_project() { + use std::os::unix::fs::symlink; + + let project = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::write(outside.path().join("app.spock"), "").unwrap(); + symlink(outside.path(), project.path().join("backend")).unwrap(); + write_manifest(project.path(), false); + + let diagnostic = load_project_from(project.path()) + .unwrap_err() + .into_vec() + .remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::PathEscape); + } + + #[cfg(unix)] + #[test] + fn permits_symlinks_whose_canonical_target_stays_inside() { + use std::os::unix::fs::symlink; + + let project = tempdir().unwrap(); + fs::create_dir(project.path().join("real-backend")).unwrap(); + fs::write(project.path().join("real-backend/app.spock"), "").unwrap(); + symlink("real-backend", project.path().join("backend")).unwrap(); + write_manifest(project.path(), false); + + let layout = load_project_from(project.path()).unwrap(); + assert_eq!( + layout.backend_root.absolute(), + &fs::canonicalize(project.path().join("real-backend")).unwrap() + ); + } + + #[cfg(unix)] + #[test] + fn rejects_a_symlinked_project_manifest() { + use std::os::unix::fs::symlink; + + let project = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let manifest = ProjectManifest::new("demo", "backend", "app.spock", None) + .unwrap() + .to_toml_string(); + fs::write(outside.path().join(MANIFEST_FILE), manifest).unwrap(); + symlink( + outside.path().join(MANIFEST_FILE), + project.path().join(MANIFEST_FILE), + ) + .unwrap(); + + let diagnostic = load_project_from(project.path()) + .unwrap_err() + .into_vec() + .remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::UnsafeSymlink); + } +} diff --git a/crates/spock-project/src/lib.rs b/crates/spock-project/src/lib.rs new file mode 100644 index 0000000..c034eb4 --- /dev/null +++ b/crates/spock-project/src/lib.rs @@ -0,0 +1,31 @@ +//! Framework project topology without language semantics or live resources. +//! +//! This crate owns the strict `spock.toml` v1 shape, nearest-root discovery, +//! contained path resolution, deterministic CLI target selection, and +//! mutation-free scaffold/adoption plans. Spock and Uhura remain responsible +//! for enumerating and capturing their own semantic inputs. + +#![forbid(unsafe_code)] + +mod diagnostic; +mod discovery; +mod layout; +mod manifest; +mod path; +mod plan; +mod starter; + +pub use diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, ProjectResult}; +pub use discovery::{discover_project_root, resolve_target, ProjectRoot, ResolvedTarget}; +pub use layout::{load_project, load_project_from, ClientLayout, ProjectLayout}; +pub use manifest::{ + parse_manifest, parse_manifest_file, BackendConfig, ClientConfig, ProjectManifest, ProjectName, + MANIFEST_FILE, MANIFEST_VERSION, +}; +pub use path::{resolve_contained, ContainedPath, NormalizedRelativePath, PathValidationError}; +pub use plan::{ + adoption_plan, is_ignored_inventory_directory, scaffold_plan, ClientTemplate, + InventoryEntryKind, PlanKind, PlannedWrite, ProjectInventory, TemplateFile, WritePlan, + DEFAULT_BACKEND_SOURCE, +}; +pub use starter::minimal_uhura_client_template; diff --git a/crates/spock-project/src/manifest.rs b/crates/spock-project/src/manifest.rs new file mode 100644 index 0000000..5ee74ac --- /dev/null +++ b/crates/spock-project/src/manifest.rs @@ -0,0 +1,597 @@ +use std::path::Path; + +use toml::Table; + +use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, ProjectResult}; +use crate::path::NormalizedRelativePath; + +pub const MANIFEST_FILE: &str = "spock.toml"; +pub const MANIFEST_VERSION: i64 = 1; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectName(String); + +impl ProjectName { + pub fn parse(value: &str) -> Result { + if value.is_empty() { + return Err("project name must not be empty".to_string()); + } + if value.trim() != value { + return Err("project name must not begin or end with whitespace".to_string()); + } + if value.chars().any(char::is_control) { + return Err("project name must not contain control characters".to_string()); + } + Ok(Self(value.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BackendConfig { + root: NormalizedRelativePath, + entry: NormalizedRelativePath, +} + +impl BackendConfig { + pub fn root(&self) -> &NormalizedRelativePath { + &self.root + } + + pub fn entry(&self) -> &NormalizedRelativePath { + &self.entry + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientConfig { + root: NormalizedRelativePath, +} + +impl ClientConfig { + pub fn root(&self) -> &NormalizedRelativePath { + &self.root + } +} + +/// The complete version-1 framework topology manifest. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectManifest { + version: u32, + project: ProjectName, + backend: BackendConfig, + client: Option, +} + +impl ProjectManifest { + pub fn version(&self) -> u32 { + self.version + } + + pub fn project(&self) -> &ProjectName { + &self.project + } + + pub fn backend(&self) -> &BackendConfig { + &self.backend + } + + pub fn client(&self) -> Option<&ClientConfig> { + self.client.as_ref() + } + + pub fn new( + project_name: &str, + backend_root: &str, + backend_entry: &str, + client_root: Option<&str>, + ) -> ProjectResult { + let mut diagnostics = Diagnostics::new(); + let project = validate_project_name(project_name, None, &mut diagnostics); + let backend_root = validate_root_path(backend_root, "backend.root", None, &mut diagnostics); + let backend_entry = + validate_file_path(backend_entry, "backend.entry", None, &mut diagnostics); + if let Some(entry) = &backend_entry { + if entry.extension() != Some("spock") { + diagnostics.push(Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + "`backend.entry` must name a `.spock` file", + )); + } + } + let client_root = + client_root.map(|root| validate_root_path(root, "client.root", None, &mut diagnostics)); + + if !diagnostics.is_empty() { + return Err(diagnostics); + } + Ok(Self { + version: MANIFEST_VERSION as u32, + project: project.expect("validated project name"), + backend: BackendConfig { + root: backend_root.expect("validated backend root"), + entry: backend_entry.expect("validated backend entry"), + }, + client: client_root.map(|root| ClientConfig { + root: root.expect("validated client root"), + }), + }) + } + + /// Render the canonical manifest form used by scaffold and adoption plans. + pub fn to_toml_string(&self) -> String { + let mut rendered = format!( + "version = {}\n\n[project]\nname = {}\n\n[backend]\nroot = {}\nentry = {}\n", + self.version, + toml_string(self.project.as_str()), + toml_string(self.backend.root.as_str()), + toml_string(self.backend.entry.as_str()), + ); + if let Some(client) = &self.client { + rendered.push_str(&format!( + "\n[client]\nroot = {}\n", + toml_string(client.root.as_str()) + )); + } + rendered + } +} + +pub fn parse_manifest(source: &str) -> ProjectResult { + parse_manifest_at(source, None) +} + +pub fn parse_manifest_file(source: &str, path: &Path) -> ProjectResult { + parse_manifest_at(source, Some(path)) +} + +fn parse_manifest_at(source: &str, path: Option<&Path>) -> ProjectResult { + let table = toml::from_str::(source).map_err(|error| { + let mut diagnostic = + Diagnostic::new(DiagnosticCode::TomlSyntax, format!("invalid TOML: {error}")); + if let Some(path) = path { + diagnostic = diagnostic.at_path(path); + } + if let Some(span) = error.span() { + diagnostic = diagnostic.with_span(span); + } + Diagnostics::one(diagnostic) + })?; + + let mut diagnostics = Diagnostics::new(); + reject_unknown_fields( + &table, + &["version", "project", "backend", "client"], + "", + path, + &mut diagnostics, + ); + + let version = integer_field(&table, "version", "version", path, &mut diagnostics); + if let Some(version) = version { + if version != MANIFEST_VERSION { + push_at( + &mut diagnostics, + Diagnostic::new( + DiagnosticCode::UnsupportedVersion, + format!( + "unsupported manifest version {version}; this tool supports version {MANIFEST_VERSION}" + ), + ), + path, + ); + } + } + + let project_table = required_table(&table, "project", path, &mut diagnostics); + let backend_table = required_table(&table, "backend", path, &mut diagnostics); + let client_table = optional_table(&table, "client", path, &mut diagnostics); + + let project = project_table.and_then(|section| { + reject_unknown_fields(section, &["name"], "project", path, &mut diagnostics); + string_field(section, "name", "project.name", path, &mut diagnostics) + .and_then(|name| validate_project_name(&name, path, &mut diagnostics)) + }); + + let (backend_root, backend_entry) = if let Some(section) = backend_table { + reject_unknown_fields( + section, + &["root", "entry"], + "backend", + path, + &mut diagnostics, + ); + let root = string_field(section, "root", "backend.root", path, &mut diagnostics) + .and_then(|root| validate_root_path(&root, "backend.root", path, &mut diagnostics)); + let entry = string_field(section, "entry", "backend.entry", path, &mut diagnostics) + .and_then(|entry| validate_file_path(&entry, "backend.entry", path, &mut diagnostics)); + if let Some(entry) = &entry { + if entry.extension() != Some("spock") { + push_at( + &mut diagnostics, + Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + "`backend.entry` must name a `.spock` file", + ), + path, + ); + } + } + (root, entry) + } else { + (None, None) + }; + + let client_root = client_table.map(|section| { + reject_unknown_fields(section, &["root"], "client", path, &mut diagnostics); + string_field(section, "root", "client.root", path, &mut diagnostics) + .and_then(|root| validate_root_path(&root, "client.root", path, &mut diagnostics)) + }); + + if !diagnostics.is_empty() { + return Err(diagnostics); + } + + Ok(ProjectManifest { + version: version.expect("validated version") as u32, + project: project.expect("validated project section"), + backend: BackendConfig { + root: backend_root.expect("validated backend root"), + entry: backend_entry.expect("validated backend entry"), + }, + client: client_root.map(|root| ClientConfig { + root: root.expect("validated client root"), + }), + }) +} + +fn required_table<'a>( + root: &'a Table, + field: &str, + path: Option<&Path>, + diagnostics: &mut Diagnostics, +) -> Option<&'a Table> { + match root.get(field) { + Some(toml::Value::Table(table)) => Some(table), + Some(_) => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::WrongType, + format!("`{field}` must be a table"), + ), + path, + ); + None + } + None => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::MissingField, + format!("missing required `[{field}]` table"), + ), + path, + ); + None + } + } +} + +fn optional_table<'a>( + root: &'a Table, + field: &str, + path: Option<&Path>, + diagnostics: &mut Diagnostics, +) -> Option<&'a Table> { + match root.get(field) { + Some(toml::Value::Table(table)) => Some(table), + Some(_) => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::WrongType, + format!("`{field}` must be a table"), + ), + path, + ); + None + } + None => None, + } +} + +fn string_field( + table: &Table, + field: &str, + qualified: &str, + path: Option<&Path>, + diagnostics: &mut Diagnostics, +) -> Option { + match table.get(field) { + Some(toml::Value::String(value)) => Some(value.clone()), + Some(_) => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::WrongType, + format!("`{qualified}` must be a string"), + ), + path, + ); + None + } + None => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::MissingField, + format!("missing required `{qualified}`"), + ), + path, + ); + None + } + } +} + +fn integer_field( + table: &Table, + field: &str, + qualified: &str, + path: Option<&Path>, + diagnostics: &mut Diagnostics, +) -> Option { + match table.get(field) { + Some(toml::Value::Integer(value)) => Some(*value), + Some(_) => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::WrongType, + format!("`{qualified}` must be an integer"), + ), + path, + ); + None + } + None => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::MissingField, + format!("missing required `{qualified}`"), + ), + path, + ); + None + } + } +} + +fn reject_unknown_fields( + table: &Table, + accepted: &[&str], + section: &str, + path: Option<&Path>, + diagnostics: &mut Diagnostics, +) { + let mut unknown = table + .keys() + .filter(|field| !accepted.contains(&field.as_str())) + .cloned() + .collect::>(); + unknown.sort(); + for field in unknown { + let qualified = if section.is_empty() { + field + } else { + format!("{section}.{field}") + }; + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::UnknownField, + format!("unknown manifest field `{qualified}`"), + ), + path, + ); + } +} + +fn validate_project_name( + value: &str, + path: Option<&Path>, + diagnostics: &mut Diagnostics, +) -> Option { + match ProjectName::parse(value) { + Ok(name) => Some(name), + Err(message) => { + push_at( + diagnostics, + Diagnostic::new(DiagnosticCode::InvalidProjectName, message), + path, + ); + None + } + } +} + +fn validate_root_path( + value: &str, + field: &str, + path: Option<&Path>, + diagnostics: &mut Diagnostics, +) -> Option { + match NormalizedRelativePath::root(value) { + Ok(path_value) => Some(path_value), + Err(error) => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + format!("invalid `{field}`: {error}"), + ), + path, + ); + None + } + } +} + +fn validate_file_path( + value: &str, + field: &str, + path: Option<&Path>, + diagnostics: &mut Diagnostics, +) -> Option { + match NormalizedRelativePath::file(value) { + Ok(path_value) => Some(path_value), + Err(error) => { + push_at( + diagnostics, + Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + format!("invalid `{field}`: {error}"), + ), + path, + ); + None + } + } +} + +fn push_at(diagnostics: &mut Diagnostics, mut diagnostic: Diagnostic, path: Option<&Path>) { + if let Some(path) = path { + diagnostic = diagnostic.at_path(path); + } + diagnostics.push(diagnostic); +} + +fn toml_string(value: &str) -> String { + let mut rendered = String::with_capacity(value.len() + 2); + rendered.push('"'); + for character in value.chars() { + match character { + '"' => rendered.push_str("\\\""), + '\\' => rendered.push_str("\\\\"), + '\n' => rendered.push_str("\\n"), + '\r' => rendered.push_str("\\r"), + '\t' => rendered.push_str("\\t"), + other => rendered.push(other), + } + } + rendered.push('"'); + rendered +} + +#[cfg(test)] +mod tests { + use super::*; + + const MINIMAL: &str = r#"version = 1 + +[project] +name = "demo" + +[backend] +root = "backend" +entry = "app.spock" +"#; + + #[test] + fn parses_the_strict_version_one_shape() { + let manifest = parse_manifest(MINIMAL).unwrap(); + assert_eq!(manifest.version, 1); + assert_eq!(manifest.project.as_str(), "demo"); + assert_eq!(manifest.backend.root.as_str(), "backend"); + assert_eq!(manifest.backend.entry.as_str(), "app.spock"); + assert!(manifest.client.is_none()); + + let with_client = + parse_manifest(&format!("{MINIMAL}\n[client]\nroot = \"client\"\n")).unwrap(); + assert_eq!(with_client.client.unwrap().root.as_str(), "client"); + } + + #[test] + fn rejects_unknown_fields_at_every_level_in_sorted_order() { + let source = r#"version = 1 +z = true +a = true +[project] +name = "demo" +other = true +[backend] +root = "backend" +entry = "app.spock" +extra = true +"#; + let diagnostics = parse_manifest(source).unwrap_err().into_vec(); + let messages = diagnostics + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect::>(); + assert_eq!( + messages, + [ + "unknown manifest field `a`", + "unknown manifest field `z`", + "unknown manifest field `project.other`", + "unknown manifest field `backend.extra`", + ] + ); + assert!(diagnostics + .iter() + .all(|diagnostic| diagnostic.code == DiagnosticCode::UnknownField)); + } + + #[test] + fn reports_missing_types_versions_and_paths_structurally() { + let diagnostics = parse_manifest( + r#"version = 2 +[project] +name = 4 +[backend] +root = "../outside" +entry = "app.txt" +[client] +unknown = true +"#, + ) + .unwrap_err(); + let codes = diagnostics + .iter() + .map(|diagnostic| diagnostic.code) + .collect::>(); + assert!(codes.contains(&DiagnosticCode::UnsupportedVersion)); + assert!(codes.contains(&DiagnosticCode::WrongType)); + assert!(codes.contains(&DiagnosticCode::InvalidManifestPath)); + assert!(codes.contains(&DiagnosticCode::UnknownField)); + assert!(codes.contains(&DiagnosticCode::MissingField)); + } + + #[test] + fn canonical_render_round_trips_and_escapes_names() { + let manifest = + ProjectManifest::new("a \\\"quoted\\\" project", ".", "app.spock", Some("client")) + .unwrap(); + let rendered = manifest.to_toml_string(); + assert!(rendered.ends_with('\n')); + assert_eq!(parse_manifest(&rendered).unwrap(), manifest); + } + + #[test] + fn syntax_diagnostic_carries_a_source_span_and_path() { + let path = Path::new("/project/spock.toml"); + let diagnostic = parse_manifest_file("version = [", path) + .unwrap_err() + .into_vec() + .remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::TomlSyntax); + assert_eq!(diagnostic.path.as_deref(), Some(path)); + assert!(diagnostic.span.is_some()); + } +} diff --git a/crates/spock-project/src/path.rs b/crates/spock-project/src/path.rs new file mode 100644 index 0000000..9a5d96c --- /dev/null +++ b/crates/spock-project/src/path.rs @@ -0,0 +1,481 @@ +use std::fmt; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, ProjectResult}; + +/// A portable, normalized path stored relative to a Spock project root. +/// +/// Manifest paths always use `/`, even on Windows. `.` is the only spelling +/// for the project root; redundant separators and dot components are rejected +/// so one logical input has one manifest spelling. +#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct NormalizedRelativePath(String); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PathValidationError(String); + +impl PathValidationError { + pub fn message(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for PathValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for PathValidationError {} + +impl NormalizedRelativePath { + /// Parse a manifest root. The special value `.` names the project root. + pub fn root(value: &str) -> Result { + Self::parse(value, true) + } + + /// Parse a non-root relative path, such as a backend entry or template + /// file. `.` is not a file path. + pub fn file(value: &str) -> Result { + Self::parse(value, false) + } + + fn parse(value: &str, allow_dot: bool) -> Result { + if value == "." { + return if allow_dot { + Ok(Self(value.to_string())) + } else { + Err(PathValidationError( + "`.` names a directory, not a file".to_string(), + )) + }; + } + if value.is_empty() { + return Err(PathValidationError("path must not be empty".to_string())); + } + if value.starts_with('/') { + return Err(PathValidationError( + "path must be relative to the project root".to_string(), + )); + } + if value.contains('\\') { + return Err(PathValidationError( + "use `/` in manifest paths; backslashes are not portable".to_string(), + )); + } + if value.contains('\0') { + return Err(PathValidationError("path contains NUL".to_string())); + } + + let segments = value.split('/').collect::>(); + if segments + .first() + .is_some_and(|segment| is_windows_drive_prefix(segment)) + { + return Err(PathValidationError( + "path must not contain a Windows drive prefix".to_string(), + )); + } + for segment in &segments { + if segment.is_empty() { + return Err(PathValidationError( + "path contains an empty segment or trailing `/`".to_string(), + )); + } + if *segment == "." { + return Err(PathValidationError( + "path contains a redundant `.` segment".to_string(), + )); + } + if *segment == ".." { + return Err(PathValidationError( + "path must not contain `..` or escape its base directory".to_string(), + )); + } + if segment.chars().any(char::is_control) { + return Err(PathValidationError( + "path contains a control character".to_string(), + )); + } + if let Some(character) = segment + .chars() + .find(|character| matches!(character, '<' | '>' | ':' | '"' | '|' | '?' | '*')) + { + return Err(PathValidationError(format!( + "path segment `{segment}` contains Windows-reserved character `{character}`" + ))); + } + if segment.ends_with('.') { + return Err(PathValidationError(format!( + "path segment `{segment}` must not end with `.`; Windows removes trailing dots" + ))); + } + if segment.ends_with(' ') { + return Err(PathValidationError(format!( + "path segment `{segment}` must not end with a space; Windows removes trailing spaces" + ))); + } + if is_windows_device_name(segment) { + return Err(PathValidationError(format!( + "path segment `{segment}` is a reserved Windows device name" + ))); + } + } + + Ok(Self(value.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn as_path(&self) -> &Path { + Path::new(&self.0) + } + + pub fn is_project_root(&self) -> bool { + self.0 == "." + } + + pub fn join(&self, child: &Self) -> Self { + match (self.is_project_root(), child.is_project_root()) { + (true, true) => Self(".".to_string()), + (true, false) => child.clone(), + (false, true) => self.clone(), + (false, false) => Self(format!("{}/{}", self.0, child.0)), + } + } + + pub fn parent(&self) -> Self { + if self.is_project_root() || !self.0.contains('/') { + return Self(".".to_string()); + } + Self( + self.0 + .rsplit_once('/') + .expect("contains slash") + .0 + .to_string(), + ) + } + + pub fn file_name(&self) -> Option<&str> { + (!self.is_project_root()) + .then(|| self.0.rsplit('/').next()) + .flatten() + } + + pub fn extension(&self) -> Option<&str> { + self.file_name() + .and_then(|name| name.rsplit_once('.').map(|(_, extension)| extension)) + } +} + +impl fmt::Display for NormalizedRelativePath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +fn is_windows_drive_prefix(segment: &str) -> bool { + let bytes = segment.as_bytes(); + bytes.len() == 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +fn is_windows_device_name(segment: &str) -> bool { + // Match the device-name guard used by the Windows capability adapter. The + // stem is the portion before the first dot, and Windows-compatible opens + // trim whitespace at the end of that stem before comparing it. + let stem = segment + .split_once('.') + .map_or(segment, |(stem, _)| stem) + .trim_end() + .to_uppercase(); + matches!( + stem.as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM0" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "COM¹" + | "COM²" + | "COM³" + | "LPT0" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + | "LPT¹" + | "LPT²" + | "LPT³" + ) +} + +/// One logical project-relative path and its canonical absolute resolution. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ContainedPath { + relative: NormalizedRelativePath, + absolute: PathBuf, +} + +impl ContainedPath { + pub fn relative(&self) -> &NormalizedRelativePath { + &self.relative + } + + pub fn absolute(&self) -> &Path { + &self.absolute + } +} + +/// Resolve an existing or not-yet-created relative path without allowing an +/// existing symlink ancestor to leave `root`. +pub fn resolve_contained( + root: &Path, + relative: &NormalizedRelativePath, +) -> ProjectResult { + let canonical_root = fs::canonicalize(root).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not resolve project root: {error}"), + ) + .at_path(root), + ) + })?; + if !canonical_root.is_dir() { + return Err(Diagnostic::new( + DiagnosticCode::WrongEntryKind, + "project root is not a directory", + ) + .at_path(root) + .into()); + } + + let candidate = canonical_root.join(relative.as_path()); + let absolute = canonicalize_with_missing_tail(&candidate) + .map_err(|diagnostic| Diagnostics::one(diagnostic.at_path(candidate.clone())))?; + if !absolute.starts_with(&canonical_root) { + return Err(Diagnostic::new( + DiagnosticCode::PathEscape, + format!( + "`{relative}` resolves outside project root {}", + canonical_root.display() + ), + ) + .at_path(candidate) + .into()); + } + + Ok(ContainedPath { + relative: relative.clone(), + absolute, + }) +} + +fn canonicalize_with_missing_tail(candidate: &Path) -> Result { + let mut cursor = candidate.to_path_buf(); + let mut tail = Vec::new(); + loop { + match fs::symlink_metadata(&cursor) { + Ok(_) => { + let mut resolved = fs::canonicalize(&cursor).map_err(|error| { + Diagnostic::new( + DiagnosticCode::Io, + format!("could not resolve path: {error}"), + ) + })?; + for segment in tail.iter().rev() { + resolved.push(segment); + } + return Ok(resolved); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let Some(name) = cursor.file_name() else { + return Err(Diagnostic::new( + DiagnosticCode::Io, + "could not find an existing ancestor for path", + )); + }; + tail.push(name.to_os_string()); + let Some(parent) = cursor.parent() else { + return Err(Diagnostic::new( + DiagnosticCode::Io, + "could not find an existing ancestor for path", + )); + }; + cursor = parent.to_path_buf(); + } + Err(error) => { + return Err(Diagnostic::new( + DiagnosticCode::Io, + format!("could not inspect path: {error}"), + )); + } + } + } +} + +/// Canonicalize an existing directory for project discovery. +pub(crate) fn canonical_directory(path: &Path) -> ProjectResult { + let canonical = fs::canonicalize(path).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not resolve directory: {error}"), + ) + .at_path(path), + ) + })?; + if !canonical.is_dir() { + return Err( + Diagnostic::new(DiagnosticCode::WrongEntryKind, "expected a directory") + .at_path(path) + .into(), + ); + } + Ok(canonical) +} + +/// Make a command target absolute and lexically normalized. Existing paths +/// are canonicalized so aliases resolve deterministically. +pub(crate) fn absolute_target(cwd: &Path, target: &Path) -> ProjectResult { + let joined = if target.is_absolute() { + target.to_path_buf() + } else { + cwd.join(target) + }; + if joined.exists() { + return fs::canonicalize(&joined).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not resolve target: {error}"), + ) + .at_path(joined), + ) + }); + } + Ok(lexically_normalize_absolute(&joined)) +} + +fn lexically_normalize_absolute(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Normal(segment) => normalized.push(segment), + } + } + normalized +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn portable_relative_paths_have_one_spelling() { + assert_eq!(NormalizedRelativePath::root(".").unwrap().as_str(), "."); + assert_eq!( + NormalizedRelativePath::file("schema/app.spock") + .unwrap() + .as_str(), + "schema/app.spock" + ); + + for invalid in [ + "", + "/tmp/app.spock", + "C:/app.spock", + "../app.spock", + "a/../b", + "a//b", + "a/./b", + "a\\b", + "a/", + ] { + assert!( + NormalizedRelativePath::file(invalid).is_err(), + "accepted {invalid:?}" + ); + } + assert!(NormalizedRelativePath::file(".").is_err()); + } + + #[test] + fn portable_paths_reject_windows_aliases_in_every_segment() { + for invalid in [ + "schema:shadow/app.spock", + "schema/app.spock:shadow", + "schema./app.spock", + "schema/app.spock.", + "schema /app.spock", + "schema/app.spock ", + "schema/app<.spock", + "schema/app>.spock", + "schema/app\".spock", + "schema/app|.spock", + "schema/app?.spock", + "schema/app*.spock", + "schema/CON/app.spock", + "schema/prn.txt", + "schema/AUX", + "schema/nul.txt", + "schema/COM0", + "schema/COM1", + "schema/lpt0.log", + "schema/lpt9.log", + "schema/COM¹", + "schema/lpt².log", + "schema/CON .txt", + "schema/com³ .log", + ] { + assert!( + NormalizedRelativePath::file(invalid).is_err(), + "accepted Windows-ambiguous path {invalid:?}" + ); + } + + for valid in [ + "app/home/page.examples.uhura", + "app/home/page.uhura", + "catalog/base.toml", + "fixtures/empty.toml", + "fixtures/scripts/empty.toml", + "uhura.toml", + ] { + assert_eq!(NormalizedRelativePath::file(valid).unwrap().as_str(), valid); + } + } + + #[test] + fn normalized_paths_join_and_parent_without_platform_dependence() { + let client = NormalizedRelativePath::root("client").unwrap(); + let manifest = NormalizedRelativePath::file("nested/uhura.toml").unwrap(); + assert_eq!(client.join(&manifest).as_str(), "client/nested/uhura.toml"); + assert_eq!(manifest.parent().as_str(), "nested"); + assert_eq!(client.parent().as_str(), "."); + } +} diff --git a/crates/spock-project/src/plan.rs b/crates/spock-project/src/plan.rs new file mode 100644 index 0000000..936c69c --- /dev/null +++ b/crates/spock-project/src/plan.rs @@ -0,0 +1,976 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use unicode_normalization::UnicodeNormalization; + +use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, ProjectResult}; +use crate::manifest::{ProjectManifest, MANIFEST_FILE}; +use crate::path::NormalizedRelativePath; + +pub const DEFAULT_BACKEND_SOURCE: &str = + "// This project has no authority contract yet. Keep this file empty until it does.\n"; + +const IGNORED_SCAN_DIRECTORIES: &[&str] = &[".git", ".spock", "node_modules", "target"]; + +/// Whether a directory is operational noise rather than an adoption input. +/// +/// Filesystem adapters outside this crate use the same policy when they walk +/// from an already-pinned directory handle. +pub fn is_ignored_inventory_directory(name: &str) -> bool { + IGNORED_SCAN_DIRECTORIES.contains(&name) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InventoryEntryKind { + File, + Directory, + Symlink, + /// A filesystem entry that is neither a regular file, directory, nor + /// symlink (for example, a Unix socket or FIFO). + Unsupported, +} + +/// A deterministic, read-only view used by pure creation/adoption planning. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectInventory { + root: PathBuf, + entries: BTreeMap, +} + +impl ProjectInventory { + pub fn empty(root: impl Into) -> Self { + Self { + root: root.into(), + entries: BTreeMap::new(), + } + } + + pub fn from_entries( + root: impl Into, + entries: impl IntoIterator, + ) -> ProjectResult { + let mut collected = BTreeMap::new(); + let mut portable_names = BTreeMap::::new(); + let mut diagnostics = Diagnostics::new(); + for (path, kind) in entries { + let key = portable_case_key(&path); + if let Some(existing) = portable_names.get(&key) { + let message = if existing == &path { + format!("inventory contains duplicate path `{path}`") + } else { + format!( + "inventory contains `{existing}` and `{path}`, which name the same destination on supported case- or normalization-insensitive filesystems" + ) + }; + diagnostics.push(Diagnostic::new(DiagnosticCode::PlanConflict, message)); + continue; + } + portable_names.insert(key, path.clone()); + collected.insert(path, kind); + } + if !diagnostics.is_empty() { + return Err(diagnostics); + } + Ok(Self { + root: root.into(), + entries: collected, + }) + } + + /// Capture names and entry kinds only. File contents remain owned by the + /// language-specific capture layers. + pub fn scan(root: &Path) -> ProjectResult { + let canonical_root = fs::canonicalize(root).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not resolve adoption root: {error}"), + ) + .at_path(root), + ) + })?; + if !canonical_root.is_dir() { + return Err(Diagnostic::new( + DiagnosticCode::WrongEntryKind, + "adoption root is not a directory", + ) + .at_path(root) + .into()); + } + + let mut entries = BTreeMap::new(); + scan_directory(&canonical_root, &canonical_root, &mut entries)?; + Self::from_entries(canonical_root, entries) + } + + pub fn root(&self) -> &Path { + &self.root + } + + pub fn entries( + &self, + ) -> impl ExactSizeIterator { + self.entries.iter().map(|(path, kind)| (path, *kind)) + } + + pub fn kind(&self, path: &NormalizedRelativePath) -> Option { + self.entries.get(path).copied() + } +} + +fn scan_directory( + root: &Path, + directory: &Path, + entries: &mut BTreeMap, +) -> ProjectResult<()> { + let mut children = fs::read_dir(directory) + .map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not scan directory: {error}"), + ) + .at_path(directory), + ) + })? + .collect::, _>>() + .map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not scan directory entry: {error}"), + ) + .at_path(directory), + ) + })?; + children.sort_by_key(fs::DirEntry::file_name); + + for child in children { + let path = child.path(); + let relative = portable_relative(root, &path)?; + let file_type = child.file_type().map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::Io, + format!("could not inspect directory entry: {error}"), + ) + .at_path(&path), + ) + })?; + let kind = if file_type.is_symlink() { + InventoryEntryKind::Symlink + } else if file_type.is_dir() { + InventoryEntryKind::Directory + } else if file_type.is_file() { + InventoryEntryKind::File + } else { + InventoryEntryKind::Unsupported + }; + entries.insert(relative.clone(), kind); + + if kind == InventoryEntryKind::Directory + && !relative + .file_name() + .is_some_and(is_ignored_inventory_directory) + { + scan_directory(root, &path, entries)?; + } + } + Ok(()) +} + +fn portable_relative(root: &Path, path: &Path) -> ProjectResult { + let relative = path.strip_prefix(root).map_err(|_| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::PathEscape, + "scanned path escaped the inventory root", + ) + .at_path(path), + ) + })?; + let mut segments = Vec::new(); + for component in relative.components() { + let std::path::Component::Normal(segment) = component else { + return Err(Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + "scanned path cannot be represented in a project manifest", + ) + .at_path(path) + .into()); + }; + let Some(segment) = segment.to_str() else { + return Err(Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + "scanned path is not UTF-8 and cannot be represented in a project manifest", + ) + .at_path(path) + .into()); + }; + segments.push(segment); + } + let portable = segments.join("/"); + NormalizedRelativePath::file(&portable).map_err(|error| { + Diagnostics::one( + Diagnostic::new( + DiagnosticCode::InvalidManifestPath, + format!("scanned path is not a valid project path: {error}"), + ) + .at_path(path), + ) + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TemplateFile { + path: NormalizedRelativePath, + contents: Vec, +} + +impl TemplateFile { + pub fn new(path: &str, contents: impl Into>) -> ProjectResult { + let path = NormalizedRelativePath::file(path).map_err(|error| { + Diagnostics::one(Diagnostic::new( + DiagnosticCode::InvalidTemplate, + format!("invalid template path `{path}`: {error}"), + )) + })?; + Ok(Self { + path, + contents: contents.into(), + }) + } + + pub fn path(&self) -> &NormalizedRelativePath { + &self.path + } + + pub fn contents(&self) -> &[u8] { + &self.contents + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClientTemplate { + files: Vec, +} + +impl ClientTemplate { + pub fn new(mut files: Vec) -> ProjectResult { + files.sort_by(|left, right| left.path.cmp(&right.path)); + let mut diagnostics = Diagnostics::new(); + for pair in files.windows(2) { + if pair[0].path == pair[1].path { + diagnostics.push(Diagnostic::new( + DiagnosticCode::InvalidTemplate, + format!("client template repeats `{}`", pair[0].path), + )); + } + } + if !files.iter().any(|file| file.path.as_str() == "uhura.toml") { + diagnostics.push(Diagnostic::new( + DiagnosticCode::InvalidTemplate, + "client template must contain `uhura.toml` at its root", + )); + } + if !diagnostics.is_empty() { + return Err(diagnostics); + } + Ok(Self { files }) + } + + pub fn files(&self) -> &[TemplateFile] { + &self.files + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlanKind { + Scaffold, + Adopt, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PlannedWrite { + pub relative_path: NormalizedRelativePath, + pub contents: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WritePlan { + pub kind: PlanKind, + pub root: PathBuf, + writes: Vec, +} + +impl WritePlan { + pub fn writes(&self) -> &[PlannedWrite] { + &self.writes + } + + pub fn write(&self, path: &str) -> Option<&PlannedWrite> { + self.writes + .iter() + .find(|write| write.relative_path.as_str() == path) + } + + /// Check all conflicts without mutating the inventory or filesystem. + pub fn preflight(&self, inventory: &ProjectInventory) -> ProjectResult<()> { + if self.root != inventory.root { + return Err(Diagnostic::new( + DiagnosticCode::PlanConflict, + format!( + "plan root {} does not match inventory root {}", + self.root.display(), + inventory.root.display() + ), + ) + .into()); + } + + let mut diagnostics = Diagnostics::new(); + let portable_entries = inventory + .entries + .iter() + .map(|(path, kind)| (portable_case_key(path), (path, *kind))) + .collect::>(); + for write in &self.writes { + if let Some((existing, kind)) = + portable_entries.get(&portable_case_key(&write.relative_path)) + { + diagnostics.push(Diagnostic::new( + DiagnosticCode::PlanConflict, + format!( + "would overwrite existing {} `{existing}` with planned path `{}`", + kind_name(*kind), + write.relative_path + ), + )); + continue; + } + let mut parent = write.relative_path.parent(); + while !parent.is_project_root() { + if let Some((existing, kind)) = portable_entries.get(&portable_case_key(&parent)) { + if *existing != &parent || *kind != InventoryEntryKind::Directory { + let reason = if *existing != &parent { + format!( + "ancestor `{parent}` aliases existing {} `{existing}` on supported case- or normalization-insensitive filesystems", + kind_name(*kind) + ) + } else { + format!("ancestor `{parent}` is an existing {}", kind_name(*kind)) + }; + diagnostics.push(Diagnostic::new( + DiagnosticCode::PlanConflict, + format!("cannot create `{}` because {reason}", write.relative_path), + )); + break; + } + } + parent = parent.parent(); + } + } + if diagnostics.is_empty() { + Ok(()) + } else { + Err(diagnostics) + } + } +} + +fn kind_name(kind: InventoryEntryKind) -> &'static str { + match kind { + InventoryEntryKind::File => "file", + InventoryEntryKind::Directory => "directory", + InventoryEntryKind::Symlink => "symlink", + InventoryEntryKind::Unsupported => "unsupported filesystem entry", + } +} + +/// Produce the canonical new-project writes without touching the destination. +pub fn scaffold_plan( + destination: impl Into, + project_name: &str, + client: Option<&ClientTemplate>, +) -> ProjectResult { + let destination = destination.into(); + if destination.as_os_str().is_empty() { + return Err(Diagnostic::new( + DiagnosticCode::PlanConflict, + "scaffold destination must not be empty", + ) + .into()); + } + let manifest = ProjectManifest::new( + project_name, + "backend", + "app.spock", + client.map(|_| "client"), + )?; + let mut writes = vec![ + planned(MANIFEST_FILE, manifest.to_toml_string().into_bytes())?, + planned( + "backend/app.spock", + DEFAULT_BACKEND_SOURCE.as_bytes().to_vec(), + )?, + ]; + if let Some(client) = client { + let client_root = + NormalizedRelativePath::root("client").expect("constant client root is valid"); + for file in client.files() { + writes.push(PlannedWrite { + relative_path: client_root.join(file.path()), + contents: file.contents.clone(), + }); + } + } + finish_plan(PlanKind::Scaffold, destination, writes) +} + +/// Plan adoption from names and entry kinds only. Existing sources are never +/// rewritten or moved. +pub fn adoption_plan( + inventory: &ProjectInventory, + project_name: Option<&str>, +) -> ProjectResult { + let manifest_path = NormalizedRelativePath::file(MANIFEST_FILE) + .expect("constant framework manifest path is valid"); + if inventory.kind(&manifest_path).is_some() { + return Err(Diagnostic::new( + DiagnosticCode::AlreadyProject, + format!("`{MANIFEST_FILE}` already exists; this directory is already adopted"), + ) + .at_path(inventory.root.join(MANIFEST_FILE)) + .into()); + } + + let backend_candidates = candidates(inventory, |path| path.extension() == Some("spock")); + let client_candidates = candidates(inventory, |path| path.file_name() == Some("uhura.toml")); + reject_ambiguous_or_symlinked( + &backend_candidates, + DiagnosticCode::AmbiguousBackend, + "Spock backend", + )?; + reject_ambiguous_or_symlinked( + &client_candidates, + DiagnosticCode::AmbiguousClient, + "Uhura client", + )?; + + let name = match project_name { + Some(name) => name.to_string(), + None => inventory + .root + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + Diagnostics::one(Diagnostic::new( + DiagnosticCode::InvalidProjectName, + "could not derive a UTF-8 project name from the adoption root", + )) + })? + .to_string(), + }; + + let (backend_root, backend_entry, create_backend) = match backend_candidates.first() { + Some((path, InventoryEntryKind::File)) => ( + path.parent(), + NormalizedRelativePath::file(path.file_name().expect("candidate is a file path")) + .expect("candidate filename remains normalized"), + false, + ), + None => ( + NormalizedRelativePath::root("backend").expect("constant root is valid"), + NormalizedRelativePath::file("app.spock").expect("constant entry is valid"), + true, + ), + Some(_) => unreachable!("symlink candidates rejected above"), + }; + let client_root = client_candidates.first().map(|(path, _)| path.parent()); + let manifest = ProjectManifest::new( + &name, + backend_root.as_str(), + backend_entry.as_str(), + client_root.as_ref().map(NormalizedRelativePath::as_str), + )?; + + let mut writes = vec![planned( + MANIFEST_FILE, + manifest.to_toml_string().into_bytes(), + )?]; + if create_backend { + writes.push(planned( + "backend/app.spock", + DEFAULT_BACKEND_SOURCE.as_bytes().to_vec(), + )?); + } + let plan = finish_plan(PlanKind::Adopt, inventory.root.clone(), writes)?; + plan.preflight(inventory)?; + Ok(plan) +} + +fn candidates( + inventory: &ProjectInventory, + predicate: F, +) -> Vec<(NormalizedRelativePath, InventoryEntryKind)> +where + F: Fn(&NormalizedRelativePath) -> bool, +{ + inventory + .entries() + .filter(|(path, kind)| { + matches!(kind, InventoryEntryKind::File | InventoryEntryKind::Symlink) + && predicate(path) + }) + .map(|(path, kind)| (path.clone(), kind)) + .collect() +} + +fn reject_ambiguous_or_symlinked( + candidates: &[(NormalizedRelativePath, InventoryEntryKind)], + ambiguity_code: DiagnosticCode, + label: &str, +) -> ProjectResult<()> { + if candidates.len() > 1 { + let mut diagnostic = Diagnostic::new( + ambiguity_code, + format!("found multiple {label} candidates; choose one explicitly"), + ); + for (path, _) in candidates { + diagnostic = diagnostic.with_note(path.to_string()); + } + return Err(diagnostic.into()); + } + if let Some((path, InventoryEntryKind::Symlink)) = candidates.first() { + return Err(Diagnostic::new( + DiagnosticCode::UnsafeSymlink, + format!("cannot adopt symlinked {label} candidate `{path}`"), + ) + .into()); + } + Ok(()) +} + +fn planned(path: &str, contents: Vec) -> ProjectResult { + let relative_path = NormalizedRelativePath::file(path).map_err(|error| { + Diagnostics::one(Diagnostic::new( + DiagnosticCode::InvalidTemplate, + format!("invalid planned path `{path}`: {error}"), + )) + })?; + Ok(PlannedWrite { + relative_path, + contents, + }) +} + +fn finish_plan( + kind: PlanKind, + root: PathBuf, + mut writes: Vec, +) -> ProjectResult { + writes.sort_by(|left, right| left.relative_path.cmp(&right.relative_path)); + let mut seen = BTreeMap::::new(); + let mut diagnostics = Diagnostics::new(); + for write in &writes { + let key = portable_case_key(&write.relative_path); + if let Some(existing) = seen.get(&key) { + let message = if existing == &write.relative_path { + format!("plan writes `{}` more than once", write.relative_path) + } else { + format!( + "plan writes `{existing}` and `{}`, which name the same destination on supported case- or normalization-insensitive filesystems", + write.relative_path + ) + }; + diagnostics.push(Diagnostic::new(DiagnosticCode::PlanConflict, message)); + } else { + seen.insert(key, write.relative_path.clone()); + } + } + + // Equality is not the only impossible file topology. A plan that writes + // both `foo` and `foo/bar` would partially mutate the destination before + // apply discovers that `foo` cannot be both a file and a directory. Check + // every portable parent key in a second pass so case or normalization + // aliases are caught even when lexical sorting places the descendant first. + let planned_paths = writes + .iter() + .map(|write| { + ( + portable_case_key(&write.relative_path), + &write.relative_path, + ) + }) + .collect::>(); + for write in &writes { + let mut parent = write.relative_path.parent(); + while !parent.is_project_root() { + if let Some(existing) = planned_paths.get(&portable_case_key(&parent)) { + diagnostics.push(Diagnostic::new( + DiagnosticCode::PlanConflict, + format!( + "cannot create `{}` because planned file `{existing}` is its ancestor", + write.relative_path + ), + )); + break; + } + parent = parent.parent(); + } + } + if !diagnostics.is_empty() { + return Err(diagnostics); + } + Ok(WritePlan { kind, root, writes }) +} + +fn portable_case_key(path: &NormalizedRelativePath) -> String { + // Windows' ordinal case-insensitive comparison is based on uppercase + // mappings. Canonical decomposition also collapses the normalization + // aliases used by supported macOS filesystems. Applying both is + // deliberately conservative for portable planning (for example, both + // Greek sigma spellings map to Σ, and é aliases e + combining acute). + path.as_str() + .chars() + .flat_map(char::to_uppercase) + .nfd() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn entry(path: &str, kind: InventoryEntryKind) -> (NormalizedRelativePath, InventoryEntryKind) { + (NormalizedRelativePath::file(path).unwrap(), kind) + } + + #[test] + fn scaffold_is_deterministic_and_mutation_free() { + let destination = PathBuf::from("/future/demo"); + let plan = scaffold_plan(&destination, "demo", None).unwrap(); + assert_eq!(plan.root, destination); + assert_eq!( + plan.writes() + .iter() + .map(|write| write.relative_path.as_str()) + .collect::>(), + ["backend/app.spock", "spock.toml"] + ); + let manifest = + std::str::from_utf8(plan.write("spock.toml").unwrap().contents.as_slice()).unwrap(); + assert!(manifest.contains("root = \"backend\"")); + assert!(!destination.exists()); + } + + #[test] + fn client_template_is_opaque_but_requires_its_manifest() { + let missing = ClientTemplate::new(vec![TemplateFile::new("app/main.uhura", "").unwrap()]); + assert_eq!( + missing.unwrap_err().into_vec()[0].code, + DiagnosticCode::InvalidTemplate + ); + + let template = ClientTemplate::new(vec![ + TemplateFile::new("uhura.toml", "[app]\nname = \"demo\"\n").unwrap(), + TemplateFile::new("app/main.uhura", "screen main {}\n").unwrap(), + ]) + .unwrap(); + let plan = scaffold_plan("demo", "demo", Some(&template)).unwrap(); + assert!(plan.write("client/uhura.toml").is_some()); + assert!(plan.write("client/app/main.uhura").is_some()); + let manifest = + std::str::from_utf8(plan.write("spock.toml").unwrap().contents.as_slice()).unwrap(); + assert!(manifest.contains("[client]")); + } + + #[test] + fn write_plan_rejects_case_insensitive_destination_aliases() { + let diagnostics = finish_plan( + PlanKind::Scaffold, + PathBuf::from("/future/demo"), + vec![ + planned("client/App/page.uhura", Vec::new()).unwrap(), + planned("client/app/PAGE.uhura", Vec::new()).unwrap(), + ], + ) + .unwrap_err(); + + assert_eq!(diagnostics.len(), 1); + let diagnostic = diagnostics.into_vec().remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::PlanConflict); + assert!(diagnostic.message.contains("client/App/page.uhura")); + assert!(diagnostic.message.contains("client/app/PAGE.uhura")); + assert!(diagnostic + .message + .contains("case- or normalization-insensitive filesystems")); + } + + #[test] + fn write_plan_rejects_file_ancestor_aliases_before_apply() { + for (ancestor, descendant) in [ + ("client/foo", "client/foo/bar.uhura"), + // The descendant sorts before the ancestor, proving validation is + // independent of the writes' lexical order. + ("client/z", "client/Z/bar.uhura"), + ("client/café", "client/cafe\u{301}/bar.uhura"), + ] { + let diagnostics = finish_plan( + PlanKind::Scaffold, + PathBuf::from("/future/demo"), + vec![ + planned(ancestor, Vec::new()).unwrap(), + planned(descendant, Vec::new()).unwrap(), + ], + ) + .unwrap_err(); + + assert_eq!(diagnostics.len(), 1, "{ancestor} versus {descendant}"); + let diagnostic = &diagnostics.iter().next().unwrap(); + assert_eq!(diagnostic.code, DiagnosticCode::PlanConflict); + assert!(diagnostic.message.contains(ancestor)); + assert!(diagnostic.message.contains(descendant)); + assert!(diagnostic.message.contains("ancestor")); + } + + let siblings = finish_plan( + PlanKind::Scaffold, + PathBuf::from("/future/demo"), + vec![ + planned("client/foo", Vec::new()).unwrap(), + planned("client/foobar/page.uhura", Vec::new()).unwrap(), + ], + ) + .unwrap(); + assert_eq!(siblings.writes().len(), 2); + } + + #[test] + fn portable_alias_checks_cover_inventory_and_unicode_uppercase_equivalence() { + let inventory_error = ProjectInventory::from_entries( + "/project", + [ + entry("client/App/page.uhura", InventoryEntryKind::File), + entry("client/app/PAGE.uhura", InventoryEntryKind::File), + ], + ) + .unwrap_err(); + assert_eq!(inventory_error.len(), 1); + assert!(inventory_error.into_vec()[0] + .message + .contains("case- or normalization-insensitive filesystems")); + + let unicode_error = finish_plan( + PlanKind::Scaffold, + PathBuf::from("/future/demo"), + vec![ + planned("client/σ.uhura", Vec::new()).unwrap(), + planned("client/ς.uhura", Vec::new()).unwrap(), + ], + ) + .unwrap_err(); + assert_eq!(unicode_error.len(), 1); + + let normalization_error = finish_plan( + PlanKind::Scaffold, + PathBuf::from("/future/demo"), + vec![ + planned("client/café.uhura", Vec::new()).unwrap(), + planned("client/cafe\u{301}.uhura", Vec::new()).unwrap(), + ], + ) + .unwrap_err(); + assert_eq!(normalization_error.len(), 1); + } + + #[test] + fn preflight_rejects_existing_case_aliases_before_any_write() { + let root = PathBuf::from("/project"); + let inventory = ProjectInventory::from_entries( + &root, + [ + entry("SPOCK.TOML", InventoryEntryKind::File), + entry("Backend", InventoryEntryKind::Directory), + ], + ) + .unwrap(); + let plan = scaffold_plan(&root, "demo", None).unwrap(); + + let diagnostics = plan.preflight(&inventory).unwrap_err(); + + assert_eq!(diagnostics.len(), 2); + let messages = diagnostics + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect::>(); + assert!(messages + .iter() + .any(|message| message.contains("SPOCK.TOML"))); + assert!(messages.iter().any(|message| message.contains("Backend"))); + } + + #[test] + fn preflight_reports_every_overwrite_and_blocking_ancestor() { + let root = PathBuf::from("/project"); + let inventory = ProjectInventory::from_entries( + &root, + [ + entry("spock.toml", InventoryEntryKind::File), + entry("backend", InventoryEntryKind::File), + ], + ) + .unwrap(); + let plan = scaffold_plan(&root, "demo", None).unwrap(); + let diagnostics = plan.preflight(&inventory).unwrap_err(); + assert_eq!(diagnostics.len(), 2); + assert!(diagnostics + .iter() + .all(|diagnostic| diagnostic.code == DiagnosticCode::PlanConflict)); + } + + #[test] + fn adoption_references_existing_sources_without_writing_them() { + let root = PathBuf::from("/project"); + let inventory = ProjectInventory::from_entries( + &root, + [ + entry("server/main.spock", InventoryEntryKind::File), + entry("experience/uhura.toml", InventoryEntryKind::File), + ], + ) + .unwrap(); + let plan = adoption_plan(&inventory, Some("adopted")).unwrap(); + assert_eq!(plan.writes().len(), 1); + let source = + std::str::from_utf8(plan.write(MANIFEST_FILE).unwrap().contents.as_slice()).unwrap(); + let manifest = crate::manifest::parse_manifest(source).unwrap(); + assert_eq!(manifest.backend().root().as_str(), "server"); + assert_eq!(manifest.backend().entry().as_str(), "main.spock"); + assert_eq!(manifest.client().unwrap().root().as_str(), "experience"); + } + + #[test] + fn uhura_only_adoption_adds_an_explicit_empty_backend() { + let root = PathBuf::from("/project"); + let inventory = + ProjectInventory::from_entries(&root, [entry("uhura.toml", InventoryEntryKind::File)]) + .unwrap(); + let plan = adoption_plan(&inventory, Some("client-first")).unwrap(); + assert!(plan.write("backend/app.spock").is_some()); + let source = + std::str::from_utf8(plan.write(MANIFEST_FILE).unwrap().contents.as_slice()).unwrap(); + let manifest = crate::manifest::parse_manifest(source).unwrap(); + assert_eq!(manifest.client().unwrap().root().as_str(), "."); + } + + #[test] + fn empty_directory_adoption_creates_only_manifest_and_empty_authority() { + let inventory = ProjectInventory::empty("/project"); + let plan = adoption_plan(&inventory, Some("empty")).unwrap(); + assert_eq!( + plan.writes() + .iter() + .map(|write| write.relative_path.as_str()) + .collect::>(), + ["backend/app.spock", "spock.toml"] + ); + let source = + std::str::from_utf8(plan.write(MANIFEST_FILE).unwrap().contents.as_slice()).unwrap(); + let manifest = crate::manifest::parse_manifest(source).unwrap(); + assert!(manifest.client().is_none()); + } + + #[test] + fn existing_framework_manifest_is_never_overwritten_by_adoption() { + let inventory = ProjectInventory::from_entries( + "/project", + [entry(MANIFEST_FILE, InventoryEntryKind::File)], + ) + .unwrap(); + let diagnostic = adoption_plan(&inventory, Some("demo")) + .unwrap_err() + .into_vec() + .remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::AlreadyProject); + } + + #[test] + fn ambiguous_adoption_lists_sorted_choices() { + let inventory = ProjectInventory::from_entries( + "/project", + [ + entry("z.spock", InventoryEntryKind::File), + entry("a.spock", InventoryEntryKind::File), + ], + ) + .unwrap(); + let diagnostic = adoption_plan(&inventory, Some("demo")) + .unwrap_err() + .into_vec() + .remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::AmbiguousBackend); + assert_eq!(diagnostic.notes, ["a.spock", "z.spock"]); + } + + #[test] + fn scan_is_sorted_does_not_follow_noise_and_keeps_declared_dist_files() { + let temp = tempdir().unwrap(); + fs::create_dir_all(temp.path().join("target/nested")).unwrap(); + fs::write(temp.path().join("target/nested/ignored.spock"), "").unwrap(); + fs::create_dir_all(temp.path().join("providers/dist")).unwrap(); + fs::write(temp.path().join("providers/dist/spock.js"), "").unwrap(); + fs::write(temp.path().join("app.spock"), "").unwrap(); + + let inventory = ProjectInventory::scan(temp.path()).unwrap(); + let paths = inventory + .entries() + .map(|(path, _)| path.as_str()) + .collect::>(); + assert!(paths.contains(&"providers/dist/spock.js")); + assert!(!paths.contains(&"target/nested/ignored.spock")); + assert!(paths.windows(2).all(|pair| pair[0] <= pair[1])); + } + + #[cfg(unix)] + #[test] + fn scan_represents_special_entries_without_adopting_them_as_sources() { + use std::os::unix::net::UnixListener; + + let temp = tempdir().unwrap(); + let socket_path = temp.path().join("app.spock"); + let _socket = UnixListener::bind(&socket_path).unwrap(); + + let inventory = ProjectInventory::scan(temp.path()).unwrap(); + let socket = NormalizedRelativePath::file("app.spock").unwrap(); + assert_eq!( + inventory.kind(&socket), + Some(InventoryEntryKind::Unsupported) + ); + + let plan = adoption_plan(&inventory, Some("demo")).unwrap(); + assert!(plan.write("backend/app.spock").is_some()); + let manifest = + std::str::from_utf8(plan.write(MANIFEST_FILE).unwrap().contents.as_slice()).unwrap(); + assert!(manifest.contains("root = \"backend\"")); + assert!(manifest.contains("entry = \"app.spock\"")); + } + + #[cfg(unix)] + #[test] + fn adoption_refuses_symlinked_semantic_roots() { + let inventory = ProjectInventory::from_entries( + "/project", + [entry("app.spock", InventoryEntryKind::Symlink)], + ) + .unwrap(); + let diagnostic = adoption_plan(&inventory, Some("demo")) + .unwrap_err() + .into_vec() + .remove(0); + assert_eq!(diagnostic.code, DiagnosticCode::UnsafeSymlink); + } +} diff --git a/crates/spock-project/src/starter.rs b/crates/spock-project/src/starter.rs new file mode 100644 index 0000000..5fc8294 --- /dev/null +++ b/crates/spock-project/src/starter.rs @@ -0,0 +1,112 @@ +use crate::plan::{ClientTemplate, TemplateFile}; + +/// The canonical, dependency-free Uhura client created by `spock new`. +/// +/// Uhura owns the meaning of these files. This crate deliberately embeds and +/// copies their bytes without parsing, rewriting, or otherwise interpreting +/// them. Keeping the starter here gives the framework CLI one versioned, +/// deterministic source for a full-stack scaffold while `scaffold_plan(..., +/// None)` remains the backend-only path. +pub fn minimal_uhura_client_template() -> ClientTemplate { + let files = [ + ( + "app/home/page.examples.uhura", + include_bytes!("../templates/minimal-client/app/home/page.examples.uhura").as_slice(), + ), + ( + "app/home/page.uhura", + include_bytes!("../templates/minimal-client/app/home/page.uhura").as_slice(), + ), + ( + "catalog/base.toml", + include_bytes!("../templates/minimal-client/catalog/base.toml").as_slice(), + ), + ( + "fixtures/empty.toml", + include_bytes!("../templates/minimal-client/fixtures/empty.toml").as_slice(), + ), + ( + "fixtures/scripts/empty.toml", + include_bytes!("../templates/minimal-client/fixtures/scripts/empty.toml").as_slice(), + ), + ( + "uhura.toml", + include_bytes!("../templates/minimal-client/uhura.toml").as_slice(), + ), + ]; + + let files = files + .into_iter() + .map(|(path, contents)| { + TemplateFile::new(path, contents) + .expect("canonical Uhura starter paths are valid project-relative files") + }) + .collect(); + ClientTemplate::new(files) + .expect("canonical Uhura starter contains one root manifest and unique files") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plan::{scaffold_plan, DEFAULT_BACKEND_SOURCE}; + + const TEMPLATE_PATHS: [&str; 6] = [ + "app/home/page.examples.uhura", + "app/home/page.uhura", + "catalog/base.toml", + "fixtures/empty.toml", + "fixtures/scripts/empty.toml", + "uhura.toml", + ]; + + #[test] + fn embedded_template_is_complete_and_deterministic() { + let first = minimal_uhura_client_template(); + let second = minimal_uhura_client_template(); + + assert_eq!(first, second); + assert_eq!( + first + .files() + .iter() + .map(|file| file.path().as_str()) + .collect::>(), + TEMPLATE_PATHS + ); + assert!(first.files().iter().all(|file| !file.contents().is_empty())); + } + + #[test] + fn full_stack_scaffold_copies_embedded_bytes_under_client_root() { + let template = minimal_uhura_client_template(); + let plan = scaffold_plan("demo", "demo", Some(&template)).unwrap(); + + for file in template.files() { + let planned_path = format!("client/{}", file.path()); + assert_eq!( + plan.write(&planned_path) + .map(|write| write.contents.as_slice()), + Some(file.contents()), + "scaffold changed opaque Uhura bytes at {planned_path}" + ); + } + assert_eq!( + plan.write("backend/app.spock") + .map(|write| write.contents.as_slice()), + Some(DEFAULT_BACKEND_SOURCE.as_bytes()) + ); + } + + #[test] + fn backend_only_scaffold_still_has_no_client_writes_or_manifest_section() { + let plan = scaffold_plan("demo", "demo", None).unwrap(); + + assert!(plan + .writes() + .iter() + .all(|write| !write.relative_path.as_str().starts_with("client/"))); + let manifest = std::str::from_utf8(&plan.write("spock.toml").unwrap().contents).unwrap(); + assert!(!manifest.contains("[client]")); + } +} diff --git a/crates/spock-project/templates/minimal-client/app/home/page.examples.uhura b/crates/spock-project/templates/minimal-client/app/home/page.examples.uhura new file mode 100644 index 0000000..acc53b8 --- /dev/null +++ b/crates/spock-project/templates/minimal-client/app/home/page.examples.uhura @@ -0,0 +1,5 @@ +use fixture empty + +example welcome default { + note "The clean starting point generated by spock new." +} diff --git a/crates/spock-project/templates/minimal-client/app/home/page.uhura b/crates/spock-project/templates/minimal-client/app/home/page.uhura new file mode 100644 index 0000000..717d2aa --- /dev/null +++ b/crates/spock-project/templates/minimal-client/app/home/page.uhura @@ -0,0 +1,14 @@ +page + + + SPOCK + UHURA + Your app is running. + Edit client/app/home/page.uhura to begin. + + + diff --git a/crates/spock-project/templates/minimal-client/catalog/base.toml b/crates/spock-project/templates/minimal-client/catalog/base.toml new file mode 100644 index 0000000..0cb8acf --- /dev/null +++ b/crates/spock-project/templates/minimal-client/catalog/base.toml @@ -0,0 +1,12 @@ +[catalog] +name = "spock-starter" +version = "0.1.0" +icons = [] + +[elements.view] +class = "layout" +children = "any" + +[elements.text] +class = "content" +children = "text" diff --git a/crates/spock-project/templates/minimal-client/fixtures/empty.toml b/crates/spock-project/templates/minimal-client/fixtures/empty.toml new file mode 100644 index 0000000..06f0707 --- /dev/null +++ b/crates/spock-project/templates/minimal-client/fixtures/empty.toml @@ -0,0 +1,2 @@ +# Intentionally empty. Add deterministic named slices here as the client +# begins consuming backend projections. diff --git a/crates/spock-project/templates/minimal-client/fixtures/scripts/empty.toml b/crates/spock-project/templates/minimal-client/fixtures/scripts/empty.toml new file mode 100644 index 0000000..654bc00 --- /dev/null +++ b/crates/spock-project/templates/minimal-client/fixtures/scripts/empty.toml @@ -0,0 +1,5 @@ +# A deterministic no-op driver for the starter page. +on-unscripted = "error" +deliver = [] +reply = [] +ui = [] diff --git a/crates/spock-project/templates/minimal-client/uhura.toml b/crates/spock-project/templates/minimal-client/uhura.toml new file mode 100644 index 0000000..8586dff --- /dev/null +++ b/crates/spock-project/templates/minimal-client/uhura.toml @@ -0,0 +1,14 @@ +[app] +name = "spock-starter" +entry = "home" + +[catalog] +path = "catalog/base.toml" + +[fixtures] +empty = "fixtures/empty.toml" + +[play.default] +fixture = "empty" +script = "empty" +allow_fixture = true diff --git a/crates/spock-project/tests/project_flow.rs b/crates/spock-project/tests/project_flow.rs new file mode 100644 index 0000000..440344b --- /dev/null +++ b/crates/spock-project/tests/project_flow.rs @@ -0,0 +1,97 @@ +use std::fs; +use std::path::Path; + +use spock_project::{ + adoption_plan, load_project_from, minimal_uhura_client_template, resolve_target, scaffold_plan, + ProjectInventory, ResolvedTarget, WritePlan, +}; +use tempfile::tempdir; + +fn apply_for_test(plan: &WritePlan) { + for write in plan.writes() { + let destination = plan.root.join(write.relative_path.as_path()); + fs::create_dir_all(destination.parent().expect("planned file has parent")).unwrap(); + fs::write(destination, &write.contents).unwrap(); + } +} + +#[test] +fn scaffolded_full_stack_project_discovers_and_loads_from_a_descendant() { + let temp = tempdir().unwrap(); + let destination = temp.path().join("demo"); + let client = minimal_uhura_client_template(); + let plan = scaffold_plan(&destination, "demo", Some(&client)).unwrap(); + plan.preflight(&ProjectInventory::empty(&destination)) + .unwrap(); + apply_for_test(&plan); + + let descendant = destination.join("client/app"); + let target = resolve_target(None, &descendant).unwrap(); + assert!(matches!(target, ResolvedTarget::Project(_))); + let layout = load_project_from(&descendant).unwrap(); + assert_eq!(layout.manifest.project().as_str(), "demo"); + assert_eq!( + layout.backend_entry.absolute(), + &fs::canonicalize(destination.join("backend/app.spock")).unwrap() + ); + assert!(layout.client.is_some()); +} + +#[test] +fn adoption_of_existing_sources_writes_only_framework_files() { + let temp = tempdir().unwrap(); + fs::create_dir_all(temp.path().join("server")).unwrap(); + fs::write( + temp.path().join("server/app.spock"), + "// existing backend\n", + ) + .unwrap(); + fs::create_dir_all(temp.path().join("experience/app")).unwrap(); + fs::write( + temp.path().join("experience/uhura.toml"), + "[app]\nname = \"existing\"\n", + ) + .unwrap(); + + let before_backend = fs::read(temp.path().join("server/app.spock")).unwrap(); + let before_client = fs::read(temp.path().join("experience/uhura.toml")).unwrap(); + let inventory = ProjectInventory::scan(temp.path()).unwrap(); + let plan = adoption_plan(&inventory, Some("existing")).unwrap(); + assert_eq!( + plan.writes() + .iter() + .map(|write| write.relative_path.as_str()) + .collect::>(), + ["spock.toml"] + ); + apply_for_test(&plan); + + assert_eq!( + fs::read(temp.path().join("server/app.spock")).unwrap(), + before_backend + ); + assert_eq!( + fs::read(temp.path().join("experience/uhura.toml")).unwrap(), + before_client + ); + let layout = load_project_from(temp.path()).unwrap(); + assert_eq!(layout.manifest.backend().root().as_str(), "server"); + assert_eq!( + layout.manifest.client().unwrap().root().as_str(), + "experience" + ); +} + +#[test] +fn explicit_spock_target_never_turns_into_project_mode() { + let temp = tempdir().unwrap(); + let project = temp.path().join("project"); + fs::create_dir_all(&project).unwrap(); + fs::write(project.join("spock.toml"), "not relevant").unwrap(); + + let target = resolve_target(Some(Path::new("new-backend.spock")), &project).unwrap(); + assert_eq!( + target, + ResolvedTarget::SpockFile(fs::canonicalize(project).unwrap().join("new-backend.spock")) + ); +} diff --git a/crates/spock-runtime/src/engine.rs b/crates/spock-runtime/src/engine.rs index c4f508b..266a0e5 100644 --- a/crates/spock-runtime/src/engine.rs +++ b/crates/spock-runtime/src/engine.rs @@ -2,8 +2,9 @@ //! the schema fresh, replay the seed through the write path. There are no //! migrations in v0 — state is disposable by doctrine (RFD 0008 §3). -use std::collections::HashMap; -use std::path::Path; +use std::collections::{BTreeMap, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; use rusqlite::types::Value as SqlValue; use rusqlite::Connection; @@ -37,6 +38,25 @@ pub fn open( contract: &Contract, path: Option<&Path>, base_dir: Option<&Path>, +) -> Result { + open_with_seed_assets(contract, path, SeedAssetSource::Filesystem(base_dir)) +} + +/// Materialize from an immutable map of already captured `file(...)` bytes. +/// The generation boundary calls this after compiling the captured source, so +/// bootstrap never rereads mutable project paths. +pub(crate) fn open_with_captured_assets( + contract: &Contract, + path: Option<&Path>, + assets: &BTreeMap>, +) -> Result { + open_with_seed_assets(contract, path, SeedAssetSource::Captured(assets)) +} + +fn open_with_seed_assets( + contract: &Contract, + path: Option<&Path>, + seed_assets: SeedAssetSource<'_>, ) -> Result { let mut conn = match path { None => Connection::open_in_memory()?, @@ -71,10 +91,48 @@ pub fn open( } validate_fns(contract, &conn)?; check_defaults(contract, &conn)?; - seed(contract, &mut conn, base_dir)?; + seed(contract, &mut conn, &seed_assets)?; Ok(conn) } +enum SeedAssetSource<'a> { + Filesystem(Option<&'a Path>), + Captured(&'a BTreeMap>), +} + +struct LoadedSeedAsset { + bytes: Vec, + metadata_path: PathBuf, +} + +impl SeedAssetSource<'_> { + fn load(&self, rel_path: &str) -> Result { + match self { + Self::Filesystem(Some(base)) => { + let full = base.join(rel_path); + let bytes = std::fs::read(&full) + .map_err(|error| format!("cannot read seed asset `{rel_path}`: {error}"))?; + Ok(LoadedSeedAsset { + bytes, + metadata_path: full, + }) + } + Self::Filesystem(None) => { + Err("file(...) seed needs a source directory; run against a .spock file".into()) + } + Self::Captured(assets) => { + let bytes = assets.get(rel_path).ok_or_else(|| { + format!("captured seed asset `{rel_path}` is missing from the input bundle") + })?; + Ok(LoadedSeedAsset { + bytes: bytes.to_vec(), + metadata_path: PathBuf::from(rel_path), + }) + } + } + } +} + /// Prove every field validator against its own literal default (RFD 0013 /// L-G). SQLite does not evaluate a `DEFAULT` against a `CHECK` until a row /// uses the default, so a default that violates its own validator would @@ -386,7 +444,7 @@ fn validate_return_columns( fn seed( contract: &Contract, conn: &mut Connection, - base_dir: Option<&Path>, + seed_assets: &SeedAssetSource<'_>, ) -> Result<(), EngineError> { // binding name -> the bound row's key value (as JSON) let mut bindings: HashMap = HashMap::new(); @@ -414,7 +472,7 @@ fn seed( SeedValue::File { path } => Json::String(seed_file( contract, conn, - base_dir, + seed_assets, path, index, &table.name, @@ -454,7 +512,7 @@ fn seed( fn seed_file( contract: &Contract, conn: &mut Connection, - base_dir: Option<&Path>, + seed_assets: &SeedAssetSource<'_>, rel_path: &str, index: usize, table_name: &str, @@ -465,17 +523,15 @@ fn seed_file( source: Box::new(ApiError::internal(message)), }; - let base = base_dir.ok_or_else(|| { - seed_err("file(...) seed needs a source directory; run against a .spock file".into()) - })?; - let full = base.join(rel_path); - let bytes = std::fs::read(&full) - .map_err(|e| seed_err(format!("cannot read seed asset `{rel_path}`: {e}")))?; + let LoadedSeedAsset { + bytes, + metadata_path, + } = seed_assets.load(rel_path).map_err(seed_err)?; - let content_type = mime_guess::from_path(&full) + let content_type = mime_guess::from_path(&metadata_path) .first_or_octet_stream() .to_string(); - let name = full + let name = metadata_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("file") diff --git a/crates/spock-runtime/src/generation.rs b/crates/spock-runtime/src/generation.rs new file mode 100644 index 0000000..d25a0ba --- /dev/null +++ b/crates/spock-runtime/src/generation.rs @@ -0,0 +1,461 @@ +//! One immutable Spock authority generation. +//! +//! A generation binds a checked contract to the database, signer, blob store, +//! authority router, and background-task lifecycle that serve it. Project +//! observation and replacement policy deliberately live above this crate: the +//! runtime can construct a generation from already captured bytes, but it does +//! not watch or reread those inputs. + +use std::collections::BTreeMap; +use std::fmt; +use std::path::Path; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::Arc; + +use axum::Router; +use rusqlite::Connection; +use sha2::{Digest, Sha256}; +use spock_lang::diag::Diagnostic; +use spock_lang::ir::Contract; + +use crate::engine::{self, EngineError}; +use crate::http::{self, StartupError}; +use crate::App; + +const LIFECYCLE_DORMANT: u8 = 0; +const LIFECYCLE_RUNNING: u8 = 1; +const LIFECYCLE_STOPPED: u8 = 2; + +/// Exact source and seed-asset bytes captured by the project layer. +/// +/// Paths are the checked `file("...")` spellings carried by the contract. +/// A `BTreeMap` makes the input fingerprint independent of insertion order. +/// Construction performs no filesystem access; callers are responsible for +/// coherently capturing the complete bundle before handing it to the runtime. +#[derive(Clone, Debug)] +pub struct CapturedBackend { + source: Arc<[u8]>, + seed_assets: BTreeMap>, + input_fingerprint: CapturedInputFingerprint, +} + +impl CapturedBackend { + pub fn new(source: impl AsRef<[u8]>, seed_assets: BTreeMap>) -> Self { + let source: Arc<[u8]> = Arc::from(source.as_ref()); + let seed_assets: BTreeMap> = seed_assets + .into_iter() + .map(|(path, bytes)| (path, Arc::from(bytes))) + .collect(); + let input_fingerprint = captured_input_fingerprint(&source, &seed_assets); + Self { + source, + seed_assets, + input_fingerprint, + } + } + + pub fn without_assets(source: impl AsRef<[u8]>) -> Self { + Self::new(source, BTreeMap::new()) + } + + pub fn source(&self) -> &[u8] { + &self.source + } + + pub fn seed_asset(&self, path: &str) -> Option<&[u8]> { + self.seed_assets.get(path).map(AsRef::as_ref) + } + + pub fn input_fingerprint(&self) -> &CapturedInputFingerprint { + &self.input_fingerprint + } +} + +/// SHA-256 identity of the exact captured source and seed-asset bundle. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct CapturedInputFingerprint(String); + +impl CapturedInputFingerprint { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CapturedInputFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// SHA-256 identity of the canonical serialized checked contract. +/// +/// This intentionally excludes materialized rows and seed-file bytes. Those +/// belong to the captured-input/world identity, while this value describes the +/// authority contract exposed to linkers and protocol consumers. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ContractFingerprint(String); + +impl ContractFingerprint { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for ContractFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// An immutable checked authority generation. +/// +/// The `App` and both fingerprints never change. Lifecycle state is a one-shot +/// ownership latch: at most one background-task guard can exist for a +/// generation, preventing duplicate storage sweepers. +pub struct BackendGeneration { + app: Arc, + contract_fingerprint: ContractFingerprint, + input_fingerprint: Option, + lifecycle: Arc, +} + +impl BackendGeneration { + /// Compile and materialize a generation solely from already captured + /// source and seed-asset bytes. + pub fn from_captured( + captured: CapturedBackend, + database_path: Option<&Path>, + ) -> Result { + let source = + std::str::from_utf8(captured.source()).map_err(BackendGenerationError::SourceUtf8)?; + let contract = spock_lang::compile(source).map_err(BackendGenerationError::Compile)?; + let conn = + engine::open_with_captured_assets(&contract, database_path, &captured.seed_assets)?; + Ok(Self::from_parts( + contract, + conn, + Some(captured.input_fingerprint), + )) + } + + /// Wrap an already constructed app. This compatibility seam lets existing + /// embedders adopt generation-owned routing/lifecycle independently of the + /// captured-input constructor. + pub fn from_app(app: Arc) -> Self { + let contract_fingerprint = contract_fingerprint(&app.contract); + Self { + app, + contract_fingerprint, + input_fingerprint: None, + lifecycle: Arc::new(AtomicU8::new(LIFECYCLE_DORMANT)), + } + } + + fn from_parts( + contract: Contract, + conn: Connection, + input_fingerprint: Option, + ) -> Self { + let app = Arc::new(App::new(contract, conn)); + let contract_fingerprint = contract_fingerprint(&app.contract); + Self { + app, + contract_fingerprint, + input_fingerprint, + lifecycle: Arc::new(AtomicU8::new(LIFECYCLE_DORMANT)), + } + } + + pub fn app(&self) -> Arc { + Arc::clone(&self.app) + } + + pub fn contract(&self) -> &Contract { + &self.app.contract + } + + pub fn contract_fingerprint(&self) -> &ContractFingerprint { + &self.contract_fingerprint + } + + pub fn input_fingerprint(&self) -> Option<&CapturedInputFingerprint> { + self.input_fingerprint.as_ref() + } + + /// Routes owned by the authority runtime only. There is deliberately no + /// fallback or CORS layer: the framework host owns application-wide route + /// partition, fallback, and cross-origin policy. + pub fn authority_router(&self) -> Result { + http::authority_router(self.app()) + } + + /// Start generation-owned background work exactly once. + /// + /// Dropping or explicitly shutting down the returned guard aborts every + /// task. A stopped generation cannot be restarted; construct a new + /// generation instead. + pub fn start_background_tasks(&self) -> Result { + let runtime = if crate::storage::storage_active(&self.app.contract) { + Some( + tokio::runtime::Handle::try_current() + .map_err(|_| BackendLifecycleError::NoRuntime)?, + ) + } else { + None + }; + self.lifecycle + .compare_exchange( + LIFECYCLE_DORMANT, + LIFECYCLE_RUNNING, + Ordering::AcqRel, + Ordering::Acquire, + ) + .map_err(|_| BackendLifecycleError::AlreadyStarted)?; + + let mut tasks = Vec::new(); + if let Some(runtime) = runtime { + tasks.push(runtime.spawn(crate::storage::sweep_loop(self.app()))); + } + Ok(BackendLifecycle { + state: Arc::clone(&self.lifecycle), + tasks, + released: false, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum BackendLifecycleError { + #[error("backend generation lifecycle has already been started")] + AlreadyStarted, + #[error("a Tokio runtime is required to start storage background tasks")] + NoRuntime, +} + +/// Explicit ownership of a generation's background tasks. +pub struct BackendLifecycle { + state: Arc, + tasks: Vec>, + released: bool, +} + +impl BackendLifecycle { + pub fn task_count(&self) -> usize { + self.tasks.len() + } + + pub async fn shutdown(mut self) { + self.abort_tasks(); + for task in self.tasks.drain(..) { + let _ = task.await; + } + self.release(); + } + + fn abort_tasks(&self) { + for task in &self.tasks { + task.abort(); + } + } + + fn release(&mut self) { + if !self.released { + self.state.store(LIFECYCLE_STOPPED, Ordering::Release); + self.released = true; + } + } +} + +impl Drop for BackendLifecycle { + fn drop(&mut self) { + self.abort_tasks(); + self.release(); + } +} + +#[derive(Debug)] +pub enum BackendGenerationError { + SourceUtf8(std::str::Utf8Error), + Compile(Vec), + Engine(EngineError), +} + +impl BackendGenerationError { + pub fn diagnostics(&self) -> Option<&[Diagnostic]> { + match self { + Self::Compile(diagnostics) => Some(diagnostics), + _ => None, + } + } +} + +impl fmt::Display for BackendGenerationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SourceUtf8(error) => write!(f, "backend source is not UTF-8: {error}"), + Self::Compile(diagnostics) => write!( + f, + "backend source has {} diagnostic(s); generation not constructed", + diagnostics.len() + ), + Self::Engine(error) => error.fmt(f), + } + } +} + +impl std::error::Error for BackendGenerationError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::SourceUtf8(error) => Some(error), + Self::Compile(_) => None, + Self::Engine(error) => Some(error), + } + } +} + +impl From for BackendGenerationError { + fn from(error: EngineError) -> Self { + Self::Engine(error) + } +} + +fn contract_fingerprint(contract: &Contract) -> ContractFingerprint { + let bytes = serde_json::to_vec(contract).expect("checked contracts always serialize"); + let mut hasher = Sha256::new(); + hasher.update(b"spock-contract/0\0"); + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + ContractFingerprint(hex::encode(hasher.finalize())) +} + +fn captured_input_fingerprint( + source: &[u8], + assets: &BTreeMap>, +) -> CapturedInputFingerprint { + let mut hasher = Sha256::new(); + hasher.update(b"spock-backend-input/0\0"); + hash_part(&mut hasher, source); + hasher.update((assets.len() as u64).to_be_bytes()); + for (path, bytes) in assets { + hash_part(&mut hasher, path.as_bytes()); + hash_part(&mut hasher, bytes); + } + CapturedInputFingerprint(hex::encode(hasher.finalize())) +} + +fn hash_part(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); +} + +#[cfg(test)] +mod tests { + use super::*; + + const STORAGE_SOURCE: &str = "auth table user { key id: uuid = auto\n \ + username: text unique\n avatar: storage_object? }\n\ + seed { user { username: \"u\", avatar: file(\"./pic.png\") } }\n"; + + #[test] + fn captured_inputs_have_order_independent_fingerprints() { + let mut first = BTreeMap::new(); + first.insert("./b.txt".to_string(), b"b".to_vec()); + first.insert("./a.txt".to_string(), b"a".to_vec()); + let mut second = BTreeMap::new(); + second.insert("./a.txt".to_string(), b"a".to_vec()); + second.insert("./b.txt".to_string(), b"b".to_vec()); + + let first = CapturedBackend::new("// source", first); + let second = CapturedBackend::new("// source", second); + assert_eq!(first.input_fingerprint(), second.input_fingerprint()); + } + + #[test] + fn captured_assets_are_the_only_seed_bytes_materialized() { + let payload = b"captured-payload".to_vec(); + let captured = CapturedBackend::new( + STORAGE_SOURCE, + BTreeMap::from([("./pic.png".to_string(), payload.clone())]), + ); + let expected_input = captured.input_fingerprint().clone(); + let generation = BackendGeneration::from_captured(captured, None).expect("generation"); + + assert_eq!(generation.input_fingerprint(), Some(&expected_input)); + let db = generation.app.db.lock().expect("db lock"); + let stored: Vec = db + .query_row("SELECT bytes FROM storage_blob", [], |row| row.get(0)) + .expect("captured blob"); + assert_eq!(stored, payload); + } + + #[test] + fn missing_captured_asset_never_falls_back_to_the_filesystem() { + let error = match BackendGeneration::from_captured( + CapturedBackend::without_assets(STORAGE_SOURCE), + None, + ) { + Ok(_) => panic!("generation unexpectedly materialized a missing captured asset"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("captured seed asset `./pic.png` is missing"), + "{error}" + ); + } + + #[test] + fn contract_and_input_fingerprints_have_distinct_meanings() { + let first = BackendGeneration::from_captured( + CapturedBackend::without_assets("table t { key id: uuid = auto }"), + None, + ) + .expect("first generation"); + let second = BackendGeneration::from_captured( + CapturedBackend::without_assets( + "// input-only comment\ntable t { key id: uuid = auto }", + ), + None, + ) + .expect("second generation"); + + assert_eq!(first.contract_fingerprint(), second.contract_fingerprint()); + assert_ne!(first.input_fingerprint(), second.input_fingerprint()); + } + + #[tokio::test] + async fn lifecycle_is_one_shot_and_explicitly_stoppable() { + let generation = BackendGeneration::from_captured( + CapturedBackend::new( + STORAGE_SOURCE, + BTreeMap::from([("./pic.png".to_string(), b"bytes".to_vec())]), + ), + None, + ) + .expect("generation"); + + let lifecycle = generation.start_background_tasks().expect("starts once"); + assert_eq!(lifecycle.task_count(), 1); + assert!(generation.start_background_tasks().is_err()); + lifecycle.shutdown().await; + assert!(generation.start_background_tasks().is_err()); + } + + #[test] + fn storage_lifecycle_without_a_runtime_is_a_clean_error() { + let generation = BackendGeneration::from_captured( + CapturedBackend::new( + STORAGE_SOURCE, + BTreeMap::from([("./pic.png".to_string(), b"bytes".to_vec())]), + ), + None, + ) + .expect("generation"); + + assert!(matches!( + generation.start_background_tasks(), + Err(BackendLifecycleError::NoRuntime) + )); + } +} diff --git a/crates/spock-runtime/src/http.rs b/crates/spock-runtime/src/http.rs index 575873c..1cfc7c5 100644 --- a/crates/spock-runtime/src/http.rs +++ b/crates/spock-runtime/src/http.rs @@ -49,7 +49,12 @@ pub enum StartupError { ReservedFilterColumn { table: String, column: String }, } -pub fn router(app: Arc) -> Result { +/// Build only the authority-owned routes. +/// +/// This composition boundary deliberately installs neither a global fallback +/// nor CORS. An embedding framework owns those application-wide policies and +/// can merge this router without letting Spock swallow sibling routes. +pub fn authority_router(app: Arc) -> Result { // `/rest/v1/rpc/{fn}` shadows `GET /rest/v1/rpc/{id}` for a table // literally named `rpc` — collisions fail startup, never requests if app.contract.table("rpc").is_some() { @@ -68,16 +73,8 @@ pub fn router(app: Arc) -> Result { } } } - let schema = graphql::schema(app.clone())?; - let gql = Router::new() - .route("/graphql/v1", get(graphiql).post(graphql_post)) - .with_state(GqlState { - schema, - app: app.clone(), - }); let mut base = Router::new() .route("/~contract", get(contract)) - .route("/~health", get(health)) .route("/~studio", get(studio_index)) .route("/~studio/", get(studio_index)) .route("/~studio/{*path}", get(studio_asset)) @@ -111,30 +108,54 @@ pub fn router(app: Arc) -> Result { ); } + // GraphQL requires at least one operation-root field. Tables derive Query + // fields and functions derive a Query or Mutation field; records alone + // are only supporting output types. An empty/comment-only backend is a + // valid authority generation, so do not try to derive an invalid empty + // schema or advertise GraphiQL for it. The embedding host owns any + // structured fallback for this deliberately unclaimed path. + if app.contract.tables.is_empty() && app.contract.fns.is_empty() { + return Ok(base.with_state(app)); + } + + let schema = graphql::schema(app.clone())?; + let gql = Router::new() + .route("/graphql/v1", get(graphiql).post(graphql_post)) + .with_state(GqlState { + schema, + app: app.clone(), + }); + + Ok(base.with_state(app).merge(gql)) +} + +/// Standalone v0 router, preserving the historical global JSON 404 and +/// permissive local-development CORS behavior. +pub fn router(app: Arc) -> Result { // Permissive CORS across the whole surface, preflight included: v0 is the // open dev tier on 127.0.0.1 (RFD 0014 — the actor header is deliberately // forgeable), so a browser client on another local origin (e.g. a Uhura // shell) may call `/graphql/v1` and `/rest/v1/rpc/{fn}` with `content-type` // and `x-spock-actor` headers. Unconditional by decision, like the reads. - Ok(base + Ok(authority_router(app)? + .route("/~health", get(health)) .fallback(not_found) - .with_state(app) - .merge(gql) .layer(CorsLayer::permissive())) } /// Serve the app on an already-bound listener until the task is stopped. /// A GraphQL schema-derivation failure (§8.2 naming laws) aborts startup. pub async fn serve(app: Arc, listener: tokio::net::TcpListener) -> std::io::Result<()> { - // The runtime owns background reconciliation: a storage contract sweeps its - // orphaned objects for the life of the server (RFD 0018 §1.6), so every - // embedder gets it — not just the CLI. The task is aborted when the server's - // runtime is dropped. - if crate::storage::storage_active(&app.contract) { - tokio::spawn(crate::storage::sweep_loop(app.clone())); - } - let router = router(app).map_err(std::io::Error::other)?; - axum::serve(listener, router).await + // Preserve standalone behavior through the explicit generation lifecycle: + // the guard owns the sweep and aborts it if this serve future is dropped. + let generation = crate::generation::BackendGeneration::from_app(app); + let lifecycle = generation + .start_background_tasks() + .map_err(std::io::Error::other)?; + let router = router(generation.app()).map_err(std::io::Error::other)?; + let result = axum::serve(listener, router).await; + lifecycle.shutdown().await; + result } /// The GraphQL route's state: the derived schema plus the app, so @@ -184,9 +205,10 @@ async fn health() -> Json { } // GET /~studio — the human-developer console (RFD 0015). A Vite/React SPA -// (crates/spock-runtime/studio) built to studio/dist, committed and embedded in -// the binary via rust-embed, served same-origin so the console is fully offline -// (no CDN): every request it makes to /~contract, /rest, /rpc, /~personas, +// (crates/spock-runtime/studio) built to studio/dist before compilation and +// embedded in the binary via rust-embed. It is served same-origin so the +// console is fully offline (no CDN): every request it makes to /~contract, +// /rest, /rpc, /~personas, // /~whoami rides `X-Spock-Actor` with no CORS. A pure consumer of the contract — // it never defines or edits schema. #[derive(RustEmbed)] diff --git a/crates/spock-runtime/src/lib.rs b/crates/spock-runtime/src/lib.rs index 78ae333..c507a53 100644 --- a/crates/spock-runtime/src/lib.rs +++ b/crates/spock-runtime/src/lib.rs @@ -9,6 +9,7 @@ pub mod engine; pub mod error; pub mod filter; pub mod func; +pub mod generation; pub mod graphql; pub mod http; pub mod storage; diff --git a/crates/spock-runtime/studio/README.md b/crates/spock-runtime/studio/README.md index dfc90c4..b790226 100644 --- a/crates/spock-runtime/studio/README.md +++ b/crates/spock-runtime/studio/README.md @@ -13,11 +13,13 @@ differentiator — **impersonate** a seed persona (the Actor selector sets The console is **not** served by a Node process at runtime. `pnpm build` compiles it to static assets in `dist/` (JS/CSS + the bundled Inter font — no -CDN). `dist/` is **committed** and embedded into the `spock` binary via +CDN). The build output is gitignored and embedded into the `spock` binary via `rust-embed` (`crates/spock-runtime/src/http.rs`, `StudioAssets`), served -same-origin at `/~studio`. So the end-user runs one command — `spock run -app.spock` — and the binary serves the console fully offline. Only a developer -editing the console needs Node. +same-origin at `/~studio`. Release jobs build and guard it before compiling the +binary; a clean source checkout keeps only `dist/.gitkeep`. So the end-user +runs one command — `spock run app.spock` — and the distributed binary serves +the console fully offline. Only a developer building or editing the console +needs Node. ## Develop diff --git a/crates/spock-runtime/tests/generation.rs b/crates/spock-runtime/tests/generation.rs new file mode 100644 index 0000000..ef9f1eb --- /dev/null +++ b/crates/spock-runtime/tests/generation.rs @@ -0,0 +1,149 @@ +//! Backend-generation composition boundary: immutable ownership, captured +//! inputs, and authority routes that do not install application-wide policy. + +use spock_runtime::generation::{BackendGeneration, CapturedBackend}; + +async fn serve(router: axum::Router) -> (String, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral listener"); + let address = listener.local_addr().expect("listener address"); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.expect("serve router"); + }); + (format!("http://{address}"), task) +} + +#[tokio::test] +async fn authority_router_has_no_global_fallback_or_cors_policy() { + let generation = BackendGeneration::from_captured( + CapturedBackend::without_assets("table note { key id: uuid = auto }"), + None, + ) + .expect("generation"); + + let (authority_base, authority_task) = serve( + generation + .authority_router() + .expect("listenerless authority router"), + ) + .await; + let authority_missing = reqwest::Client::new() + .get(format!("{authority_base}/belongs-to-the-framework")) + .header("Origin", "http://127.0.0.1:8787") + .send() + .await + .expect("authority request"); + assert_eq!(authority_missing.status(), reqwest::StatusCode::NOT_FOUND); + assert!( + authority_missing + .headers() + .get("access-control-allow-origin") + .is_none(), + "the embedding host owns CORS" + ); + assert_eq!(authority_missing.text().await.unwrap(), ""); + assert_eq!( + reqwest::get(format!("{authority_base}/~health")) + .await + .expect("authority health request") + .status(), + reqwest::StatusCode::NOT_FOUND, + "aggregate health belongs to the embedding host" + ); + authority_task.abort(); + + // The compatibility router retains standalone Spock's JSON fallback and + // permissive CORS; splitting authority routes did not change public v0. + let (standalone_base, standalone_task) = + serve(spock_runtime::http::router(generation.app()).expect("standalone router")).await; + let standalone_missing = reqwest::Client::new() + .get(format!("{standalone_base}/belongs-to-the-framework")) + .header("Origin", "http://127.0.0.1:8787") + .send() + .await + .expect("standalone request"); + assert_eq!(standalone_missing.status(), reqwest::StatusCode::NOT_FOUND); + assert_eq!( + standalone_missing.headers()["access-control-allow-origin"], + "*" + ); + assert!(standalone_missing + .text() + .await + .expect("fallback body") + .contains("no such path")); + assert_eq!( + reqwest::get(format!("{standalone_base}/~health")) + .await + .expect("standalone health") + .status(), + reqwest::StatusCode::OK + ); + standalone_task.abort(); +} + +#[test] +fn empty_captured_backend_constructs_an_authority_generation() { + let generation = BackendGeneration::from_captured(CapturedBackend::without_assets(""), None) + .expect("empty backend is a valid authority generation"); + + assert!(generation.contract().tables.is_empty()); + assert!(generation.contract().records.is_empty()); + assert!(generation.contract().fns.is_empty()); +} + +#[tokio::test] +async fn comment_only_authority_exposes_contract_without_graphql() { + let generation = BackendGeneration::from_captured( + CapturedBackend::without_assets("// backend intentionally empty\n"), + None, + ) + .expect("comment-only backend is a valid authority generation"); + let (base, task) = serve( + generation + .authority_router() + .expect("empty authority router"), + ) + .await; + + let graphql = reqwest::get(format!("{base}/graphql/v1")) + .await + .expect("GraphQL path request"); + assert_eq!(graphql.status(), reqwest::StatusCode::NOT_FOUND); + assert_eq!( + graphql.text().await.expect("unclaimed response body"), + "", + "the embedding host, not Spock, owns structured fallback" + ); + + let contract = reqwest::get(format!("{base}/~contract")) + .await + .expect("contract request"); + assert_eq!(contract.status(), reqwest::StatusCode::OK); + let contract: serde_json::Value = contract.json().await.expect("contract JSON"); + assert_eq!(contract["spock"], "v0"); + assert_eq!(contract["tables"], serde_json::json!([])); + assert_eq!(contract["fns"], serde_json::json!([])); + + task.abort(); +} + +#[test] +fn generation_owns_a_stable_contract_identity() { + let generation = BackendGeneration::from_captured( + CapturedBackend::without_assets("table note { key id: uuid = auto }"), + None, + ) + .expect("generation"); + + assert_eq!(generation.contract().tables[0].name, "note"); + assert_eq!(generation.contract_fingerprint().as_str().len(), 64); + assert_eq!(generation.input_fingerprint().unwrap().as_str().len(), 64); + + // Consumers share the exact owned App rather than reconstructing runtime + // state for each router or request. + let first = generation.app(); + let second = generation.app(); + assert!(std::sync::Arc::ptr_eq(&first, &second)); +} diff --git a/docs/rfd/0015-studio.md b/docs/rfd/0015-studio.md index cd08421..5f3c16c 100644 --- a/docs/rfd/0015-studio.md +++ b/docs/rfd/0015-studio.md @@ -1,16 +1,16 @@ # RFD 0015 — Studio: the human-developer layer -Status: **discussion draft**. A stance is recommended here; four framing -decisions were settled with the author before drafting (§2), and open questions -for ratification are in §12. Studio is *ecosystem tooling* — a local web app -over the introspectable contract — so it rides alongside the language roadmap -(the filter RFD is still the immediate next language milestone), never competing -for the differentiator slot. It proposes one small, already-designed backend -addition (§6); the app itself is a pure consumer of a running `spock run`. +Status: **accepted and implemented**. Four framing decisions were settled with +the author before drafting (§2), and §12 records how implementation resolved +the remaining choices. Studio is *ecosystem tooling* — a local web app over the +introspectable contract — so it rides alongside the language roadmap, never +competing for the differentiator slot. It added one small backend seam (§6); +the app itself remains a pure consumer of an active Spock authority generation, +whether served by `spock run`, `spock start`, or `spock dev`. The name "Studio" is provisional and fully reversible. -**Implementation status (2026-07-12).** Shipped in the runtime: the two +**Implementation status (2026-07-15).** Shipped in the runtime: the two endpoints (`/~personas`, `/~whoami`) and a working console at `/~studio`, a Supabase-shaped three-pane shell (rail · object list · work area) with a **neutral black/white palette** — the Actor persona selector sits where a DB @@ -18,14 +18,20 @@ studio parks its role dropdown. The console evolved through three realizations; the **current** one is a **Vite + React + TypeScript SPA** styled with **Tailwind v4 + shadcn/ui** (the Mira / neutral preset `b1D0dv72`, Inter vendored, lucide icons) and **react-data-grid** for the table view -(`crates/spock-runtime/studio/`). `pnpm build` compiles it to a committed -`dist/` that is embedded via **`rust-embed`** and served same-origin at -`/~studio` (+ hashed assets under `/~studio/{*path}`) — the console stays fully -offline (no CDN). This **reverses the "no bundler / cargo-only CI" call in -Q1/Q6/§7**: studio is now a real front-end app with an authoring-time Node build +(`crates/spock-runtime/studio/`). `pnpm build` compiles it to a gitignored +`dist/` that release CI guards and embeds via **`rust-embed`**. Only +`dist/.gitkeep` is committed so a clean checkout still compiles; a useful +source-built Studio requires the SPA build before `cargo build`. It is served +same-origin at `/~studio` (+ hashed assets under `/~studio/{*path}`) — the +console stays fully offline (no CDN). This **reversed the original "no bundler / +cargo-only CI" call recorded in Q1/Q6/§7**: Studio is now a real front-end app with an +authoring-time Node build (`pnpm build` → `cargo build`); the single-`spock`-binary + offline guarantees -are preserved (the bundle and font embed into the binary). The MVP surface -(§5.1) is complete; edit and filter stay deferred (§5.2). +are preserved (the bundle and font embed into the binary). The original MVP +surface (§5.1) is complete; the shipped Studio now also consumes RFD 0021's +server-side filtering, ordering, and offset paging and can create rows through +the compiled GraphQL contract. Updating or deleting existing rows remains +deferred (§5.2). --- @@ -35,7 +41,8 @@ Spock already publishes everything a human needs to *see* their prototype: the compiled contract as data (`/~contract`), open reads (`/rest/v1/{table}`), the deliberate surface (`/rest/v1/rpc/{fn}` + GraphQL), and — since the actor seam shipped in the runtime (RFD 0014) — a dev-time impersonation knob -(`X-Spock-Actor`). What is missing is the *console*: a place a developer opens, +(`X-Spock-Actor`). What was missing when this RFD was written was the *console*: +a place a developer opens, sees the shape of the world they declared, and **plays it as maya, then as luis, then as nobody** — watching the same fn answer differently each time. @@ -52,13 +59,12 @@ exactly where a language quietly grows a second source of truth. This RFD's job is to draw the walls that keep Studio a consumer, and to scope an MVP that proves the headline without waiting on unshipped language work. -**Why now.** The actor seam just shipped in the runtime but is invisible to -humans and unexercised — you reach it only by hand-setting `X-Spock-Actor` on a -`curl`. A persona switcher is the cheapest way to make the just-built seam -demonstrable and to pressure-test its resolution before the filter RFD and -`policy` build on it. Studio does not outrank the filter RFD on the language -track — it is not language work (§3); it rides alongside, and §7/§9 keep it -honestly subordinate. +**Why it landed.** The actor seam was invisible to humans and reachable only by +hand-setting `X-Spock-Actor` on a `curl`. A persona switcher was the cheapest +way to make that seam demonstrable and pressure-test its resolution before +`policy` builds on it. Studio did not outrank the filter work on the language +track — it is not language work (§3); RFD 0021 subsequently shipped, and Studio +now consumes its query surface without owning a second filter grammar. ## 1. What already exists to build on @@ -73,8 +79,9 @@ runtime, July 2026): `readonly`, `params`, `returns`, `errors[]`, `refusals[]`, and the raw statement bodies `sql[]`. - **`GET /~health`** — `{"ok": true}`. -- **`GET /rest/v1/{table}`** (`?limit`, default 50 / max 200; envelope - `{"rows":[...]}`) and **`GET /rest/v1/{table}/{id}`** (single-key tables). +- **`GET /rest/v1/{table}`** (PostgREST-shaped column predicates, `order`, + `limit`, and `offset`; default 50 / max 200; envelope `{"rows":[...]}`) and + **`GET /rest/v1/{table}/{id}`** (single-key tables). - **`POST /rest/v1/rpc/{fn}`** (JSON args) and **`GET /rest/v1/rpc/{fn}`** (read fns, query-string args; a `mut` fn → 405). - **`POST /graphql/v1`** and **`GET /graphql/v1`** (GraphiQL; note its assets @@ -86,30 +93,31 @@ runtime, July 2026): (`default: {"kind":"actor"}`) are server-stamped and removed from the GraphQL insert/update surface. -Studio adds **zero** new render sources. It adds two tiny *actor* endpoints (§6) -that RFD 0014 §4.3 already specced (as part of the recommended seam) but left -unimplemented when the seam's core shipped. +Studio added **zero** new render sources. It added two tiny *actor* endpoints +(§6) that RFD 0014 §4.3 had already specified as part of the recommended seam +but left unimplemented when the seam's core shipped. ## 2. The four decisions, settled Before drafting, four open decisions were settled with the author: 1. **Host.** Studio is **served by the `spock` binary at `/~studio`** — a - self-contained page embedded in the binary, served **same-origin** (exactly - as GraphiQL is served today). *As shipped* it is one no-bundler page - (`include_str!`); a Vite/`rust-embed` build is the growth path (§7). + Vite-built React SPA embedded with `rust-embed` and served **same-origin** + (exactly as GraphiQL is served today). It remains one runtime process and + requires no Node installation for an end user (§7). `spock run` → open `http://127.0.0.1:4000/~studio`. This makes the headline - feature ship *out of the box* and sidesteps the fact that the server sends no - CORS headers (§7). + feature ship *out of the box*; same-origin is the primary browser boundary + even though the standalone development router also permits local CORS (§7). 2. **Name.** "Studio" is kept as the provisional working name. -3. **Scope.** The MVP is **read + impersonate + run** (§5.1); inline row - **editing** and table **filtering** are deferred (§5.2), because both depend - on language work that has not shipped (REST writes; the filter RFD). +3. **Scope.** The MVP is **read + impersonate + run** (§5.1). RFD 0021 later + supplied filtering, ordering, and offset paging, and Studio added + contract-derived row creation through GraphQL. Updating or deleting existing + rows remains deferred (§5.2). 4. **Backend-first.** The two enabling endpoints (`/~personas`, `/~whoami`) ship **first**, server-side (§6). `/~whoami` is genuinely *authoritative* — it echoes the server's own key-type canonicalization, which a client cannot reliably replicate; `/~personas` is a canonical, DRY projection of the picker - (its live-vs-seed row source is open — §12 Q2). + from the anchor table's current rows (§12.2). A full design panel (as in RFD 0013 §2 / 0014 §2) was deliberately skipped: Wall 3 scopes Studio too small to earn that budget, and the substantive trade-offs @@ -199,15 +207,17 @@ yet have would be the exact dishonesty RFD 0004 §1 warns against. Studio's valu here is the opposite: it makes the ungoverned floor *visible* (§8), which correctly prioritizes closing it. -## 5. Scope — MVP now, honest about what waits +## 5. Scope — shipped surface, honest about what waits -### 5.1 NOW (read + impersonate + run) +### 5.1 SHIPPED (read + impersonate + run) 1. **Schema / contract browser** — tables, fields, refs (as a graph), records, and fns, rendered from `/~contract`. Each fn shows its signature, its declared error set, and its refusals. -2. **Table + row viewer** — `GET /rest/v1/{table}` with the `limit` control; a - single-row view via `/{id}` for single-key tables. Read-only. +2. **Table + row viewer** — `GET /rest/v1/{table}` with server-side predicates, + multi-column ordering, `limit`, and bounded `offset` paging. The grid remains + read-only for existing rows; **Add row** derives a GraphQL insert form from + the compiled contract, including `= me`, references, and file fields. 3. **Persona switcher — the differentiator.** A dropdown populated from `/~personas`; selecting a persona sets `X-Spock-Actor` on *every* subsequent request. An "anonymous" entry sends no header. `/~whoami` echoes the resulting @@ -216,7 +226,7 @@ correctly prioritizes closing it. `GET /rest/v1/rpc/{fn}`, others `POST`; the response (and any derived error envelope, with its `code`/`kind`/`fields`/`message`) is rendered. Run the same fn under different personas and diff the outcomes. -5. **Embedded GraphQL** — the existing GraphiQL, reachable from Studio (§1: its +5. **GraphQL entry** — the existing GraphiQL is linked from Studio (§1: its assets load from a CDN — blank offline). Borrowed wholesale. 6. **The surface ledger** — the v0 slice (§8): identity anchor, `= me`-stamped columns, per-op error/refusal sets, and the ungoverned-floor warning. @@ -225,22 +235,24 @@ correctly prioritizes closing it. Five things wait — the canonical list, with what each blocks on, is §11: -- **Inline row editing** — read-only until REST writes land; the writes that *do* - exist go through the fn runner / GraphQL (correct: the deliberate surface). -- **Table filtering / sort / keyset paging** — `limit`-only until the filter RFD. +- **Existing-row update/delete** — the grid stays read-only; creation goes + through the compiled GraphQL insert surface, while broader editing waits on a + deliberate write UX and protocol boundary. +- **Exact counts and safe keyset cursors** — the shipped pager is bounded + `offset`, honestly labeled; it does not pretend to be a cursor. - **Authoring-time scaffold to `.spock`** — additive fast-follow (Wall 2). - **Live views / effect streams (WS/SSE)** — post-v0. - **Hostable / tunneled Studio** — local-only for MVP. Studio makes each gap *visible* rather than faking the capability. -## 6. The two enabling endpoints (ship first) +## 6. The two enabling endpoints Both are additive `~`-meta endpoints next to `/~contract` and `/~health`, and -both come straight from RFD 0014 §4.3, which specced them as part of the -recommended seam but shipped no implementation. -They are the only backend change this RFD proposes — ~30 lines, no new concepts, -read-only, and forward-compatible with the v1 GoTrue swap. +both came straight from RFD 0014 §4.3, which specified them as part of the +recommended seam but shipped no implementation. They are the only backend +change this RFD introduced: read-only and forward-compatible with the v1 GoTrue +swap. ### 6.1 `GET /~personas` — the picker @@ -259,10 +271,10 @@ GET /~personas text column the label degrades to the raw key — the picker still lets you *select* a real actor rather than type one, but authors wanting recognizable labels should give the anchor a unique text column, as `user.username` does. -- Rows come from the anchor table's current contents (proposed; live-rows vs - seed-projection is open — §12 Q2), which in v0 *are* the seed personas - (RFD 0014: "a persona in v0 is a seed row in the anchor table"). Cap the - projection (proposed: 100) so a large dev DB can't blow up the picker. +- Rows come from the anchor table's current contents, which begin as the seed + personas in v0 and reflect later inserts in the active generation. The + projection is capped at 100 rows and ordered by the anchor key so a large dev + database cannot blow up the picker (§12.2–3). - **No `auth table`** → `[]`. Studio then shows "no identity table — impersonation unavailable" instead of an empty dropdown that looks broken. @@ -298,7 +310,7 @@ GET /~whoami (with or without X-Spock-Actor) as anonymous. It answers "am I sending the header right?" and "why does my guard match nothing?". -### 6.3 Implementation sketch (for the milestone, not this RFD to fix) +### 6.3 Implementation record - Register both routes in `router()` (`crates/spock-runtime/src/http.rs`). - `/~personas`: `app.contract.anchor()` gives the table; find its key and its @@ -311,8 +323,9 @@ GET /~whoami (with or without X-Spock-Actor) via the same `path_key_value`; a value that fails to parse → `anonymous: false, known: false`; a value that parses → `known = EXISTS(SELECT 1 FROM WHERE = ?)`. -- No CORS needed — same-origin (§7). Both endpoints are read-only and touch no - write path. +- Studio calls both endpoints same-origin (§7). The standalone development + router also permits cross-origin local clients; the combined host owns its + public transport policy. Both endpoints are read-only and touch no write path. **Forward-compat (RFD 0014 §9):** `/~whoami` becomes GoTrue's `GET /user` under v1 auth; `/~personas` becomes a dev-flag-gated seed-persona picker; the @@ -321,41 +334,32 @@ which is stable across that swap. ## 7. Host & architecture -**Served by the binary at `/~studio`, same-origin.** The `spock` binary already -serves a same-origin browser UI (GraphiQL) from an in-binary HTML shell; Studio -extends that posture. As shipped it is a **single self-contained page** (vanilla -HTML/CSS/JS, no framework, no CDN — fully offline) at -`crates/spock-runtime/studio/index.html`, embedded via `include_str!` and served -at `/~studio`. A Vite/`rust-embed` build is the documented growth path if the -page outgrows one file; a framework was deliberately deferred (Wall 3). -Consequences: - -- **Ships out of the box.** No `npm install`, no second process — `spock run` and - the console is there. That is what shipping the headline feature out of the box - demands. (One honest bound: the *borrowed* GraphiQL screen still fetches its - assets from a CDN, so "out of the box" means install-free and single-process, - not fully offline — vendoring GraphiQL's assets via `rust-embed` is a - fast-follow.) -- **No CORS problem.** The server sends *no* `Access-Control-*` headers today, - and the custom `X-Spock-Actor` header plus JSON bodies both force an `OPTIONS` - preflight that would 404. Same-origin serving avoids the question entirely — and - avoids adding a security-shaped `CorsLayer` to the language runtime for the sake - of tooling (a Wall-3 smell). -- **Dev loop.** Edit `crates/spock-runtime/studio/index.html` and rebuild — the - page is `include_str!`-embedded, so `cargo build` picks it up. No dev server is - required today; a future Vite build would proxy to `127.0.0.1:4000` - (server-side — no CORS). -- **Cost, stated plainly.** As shipped there is **no new toolchain**: the page is - a committed `.html` embedded via `include_str!`, so CI stays cargo-only and the - binary stays single-file. This is the lightest realization of the host decision - and best-honors Wall 3. The Node/Vite cost only arrives *if* the page later - grows into a bundled SPA — a deliberate future choice, not a v0 commitment. -- **Framework.** None (resolved). The console is ~6 views over `fetch` in vanilla - JS — small enough that a framework would be pure overhead. One can be adopted - later without changing the host decision. No GraphQL client beyond the borrowed - GraphiQL. - -`spock run` gains a startup-banner line advertising `/~studio`, matching the +**Served by the binary at `/~studio`, same-origin.** Studio is a Vite + React + +TypeScript SPA under `crates/spock-runtime/studio/`, styled with Tailwind and +shadcn/ui and built to gitignored `dist/`. `rust-embed` places the built JS, +CSS, and font in the native binary, which serves the SPA and its history +fallback at `/~studio`; Node is an authoring/build dependency, never a runtime +dependency. Consequences: + +- **Ships out of the box.** No runtime `npm install`, no second process — a + distributed `spock` binary already contains the console. The Studio assets + and vendored font are offline; the separately borrowed GraphiQL screen still + fetches its own assets from a CDN. +- **CORS ownership stays explicit.** Studio itself uses same-origin requests. + The standalone language server applies permissive local-development CORS, + including `OPTIONS`, for browser clients on another local origin. The + listener-free authority router deliberately applies none because `spock-host` + owns the combined public listener and its transport policy. +- **Dev loop.** `pnpm dev` runs Vite with HMR and proxies the Spock protocol to + `127.0.0.1:4000`; `pnpm build` regenerates `dist/`, and the following Cargo + build embeds it. Release CI performs and guards that sequence. +- **Cost, stated plainly.** Studio contributors and release jobs need the pinned + Node/pnpm toolchain; users still receive a single native process with no Node + runtime dependency. The framework is justified by the shipped multi-view, + data-grid, filtering, paging, and contract-derived write surface; it does not + move authority into the client. + +`spock run` prints a startup-banner line advertising `/~studio`, matching the existing banner style. ## 8. The surface ledger — the v0 slice @@ -397,9 +401,9 @@ heuristic, never as contract truth. The authoritative signals are the anchor, th - **Not an access-control demo it can't back.** It does not gate row browsing by persona, because v0 doesn't (§4.1). It shows the floor is open, rather than pretending it's closed. -- **Not a client library.** It does not squat the reserved `spock` npm name - (that's the future generic protocol client, RFD 0010) and does not ship a - data-layer package. It may *consume* `spock gen types`. +- **Not a client library.** The `spock` npm name now belongs to the framework + CLI distribution; Studio does not create a second data-layer package or + claim authority over application data. It may *consume* `spock gen types`. - **Not a migration/ops tool.** No DB management, no seed regeneration UI (seed regeneration is a deliberate authoring-time act, RFD 0002 §2). - **Not on the language critical path.** If Studio and a language milestone @@ -411,19 +415,24 @@ heuristic, never as contract truth. The authoritative signals are the anchor, th - The ledger widens automatically: when `role`/`view`/`policy` land, the same screen gains the role and via columns of RFD 0004 §5 — Studio renders whatever the contract grows, additively (RFD 0014 §8 keeps the contract additive). -- When REST writes and the filter dialect land, the deferred edit/filter surfaces - (§5.2) unlock with no architectural change — the row viewer already speaks - `/rest/v1/{table}`. +- RFD 0021's filter dialect already widened the row viewer without an + architectural change. Contract-derived creation now uses GraphQL; future + update/delete UX can consume a deliberate write boundary without changing + Studio's authority posture. - The `X-Spock-Actor` knob is stable; a v1 `Authorization: Bearer` path is a second, additive credential source Studio can offer alongside the dev header. ## 11. Deferrals — every one named -1. Inline row **editing** — waits on REST writes (RFD 0009 track 5). -2. Table **filter / sort / keyset paging** UI — waits on the filter RFD (track 3). +1. Existing-row **update/delete** — creation is shipped through the compiled + GraphQL insert surface; the data grid itself remains read-only. +2. **Exact counts and safe keyset cursors** — filtering, ordering, and bounded + offset paging are shipped; a deep offset is not presented as a stable cursor. 3. **Authoring-time scaffold** to `.spock` — additive, Wall-2-bound, fast-follow. 4. **Live views / effect streams** — post-v0. -5. **Hostable / tunneled** Studio, and any **`CorsLayer`** — local-only for MVP. +5. **Hostable / tunneled** Studio and framework-host cross-origin policy — + local-only for MVP. The standalone language server's permissive development + CORS does not turn Studio into a hosted surface. 6. **`reads_actor`** authoritative bit — deferred by RFD 0014 §8; Studio uses a labeled heuristic meanwhile. 7. **Role / policy / view** ledger columns — arrive with v1 governance. @@ -434,36 +443,36 @@ heuristic, never as contract truth. The authoritative signals are the anchor, th implementation work (S2). Exact counts and safe keyset cursors remain the protocol findings already recorded in `examples/filter-lab/FEEDBACK.md`. -## 12. Open questions (for ratification) - -1. ~~**Framework.**~~ **Resolved:** the MVP shipped no-framework (vanilla, - `include_str!`-embedded) — the smallest, fastest, most Wall-3-aligned option. - Revisit only if the page outgrows a single file. -2. **`/~personas` source — live rows vs seed projection.** Proposed: the anchor - table's *current* rows (reflects inserts during a session). Alternative: only - `contract.seed` rows (stable, but stale after inserts). (Lean: live rows, capped.) -3. **`/~personas` cap and ordering.** Proposed cap 100; ordering by key. Is a - deterministic order (seed order) worth preserving? -4. **Startup default.** Should `spock run` print/open `/~studio` by default, or - behind a `--studio` flag / a `spock studio` subcommand? (Lean: serve always, - advertise in the banner, never auto-open a browser.) -5. **Heuristic actor-read hint.** Is the `fn.sql` substring scan (§8) worth - showing at all in v0, given RFD 0014 deliberately deferred `reads_actor` as - fragile? (Lean: show it, clearly labeled "heuristic," because the alternative - is the developer scanning bodies by hand.) -6. ~~**CI posture for the Node build.**~~ **Moot as shipped:** there is no Node - build — the page is a committed `.html` embedded via `include_str!`, so CI - stays cargo-only. This returns only if a bundled SPA is later adopted. +## 12. Decisions resolved by implementation + +1. **Framework.** Vite + React + TypeScript, styled with Tailwind/shadcn and + embedded with `rust-embed`. This changed the authoring implementation, not + the one-process/offline runtime boundary. +2. **`/~personas` source.** Current rows from the anchor table, so inserts in the + running generation are reflected. +3. **`/~personas` cap and ordering.** At most 100 rows, ordered by the canonical + anchor key. +4. **Startup default.** Always serve Studio and advertise it in the startup + banner; never open a browser automatically. +5. **Heuristic actor-read hint.** Show a clearly non-authoritative scan for + `spock_actor(` in function SQL. The contract still does not claim a + `reads_actor` bit. +6. **CI posture for the Node build.** The pinned Node/pnpm build is accepted and + required before the Rust build in release CI. The resulting assets embed in + the binary, so Node remains absent from the user runtime. ## 13. What ships, in one paragraph -A `spock run` server gains two read-only `~`-endpoints — `/~personas` (the anchor +A `spock run` server exposes two read-only `~`-endpoints — `/~personas` (the anchor table projected to `{actor, label}`) and `/~whoami` (`{actor, anonymous, known}`, never rejects) — and serves a same-origin SPA at `/~studio`. Studio is a pure consumer of `/~contract`: it browses the schema, inspects rows over -`/rest/v1/{table}`, runs fns over `/rest/v1/rpc/{fn}`, embeds GraphiQL, and renders -the v0 surface ledger. Its differentiator is a persona switcher that sets +`/rest/v1/{table}` with server-side filters, ordering, and bounded offset paging, +creates rows through the compiled GraphQL insert surface, runs fns over +`/rest/v1/rpc/{fn}`, links GraphiQL, and renders the v0 surface ledger. Its +differentiator is a persona switcher that sets `X-Spock-Actor` on every request, so fns and `= me` write-stamps re-answer as maya, luis, or anonymous — the executable PRD, played. It never edits schema, never gates what the floor doesn't gate, and never competes with the language roadmap: -editing and filtering wait, visibly, on REST writes and the filter RFD. +existing-row update/delete, exact counts, and keyset cursors remain explicit +deferrals rather than invented capabilities. diff --git a/docs/rfd/0020-distribution.md b/docs/rfd/0020-distribution.md index 599cd77..9b65514 100644 --- a/docs/rfd/0020-distribution.md +++ b/docs/rfd/0020-distribution.md @@ -1,25 +1,36 @@ # RFD 0020 — Distribution: shipping the `spock` binary -Status: **accepted; implemented and verified on `main`** (2026-07-13). v0 -delivery is **npm-only** — every other channel is deferred behind it (§2). The -pipeline (`.github/workflows/npm.yml`) builds four platform binaries, publishes -the single `spock` package tokenlessly via OIDC trusted publishing, and -verifies install-and-run on macOS/Linux/Windows — `npx spock` works. First -published version: **`0.1.3`**. Three v0 simplifications departed from the +Status: **accepted**. The original npm-only binary pipeline was implemented and +verified on `main` (2026-07-13); the RFD 0022 framework-sidecar extension is +implemented in the release workflow, and its first `0.5.0` full-matrix dry run +passed on 2026-07-15 +([Actions run 29379605382](https://github.com/gridaco/spock/actions/runs/29379605382)). +v0 delivery remains **npm-only** — every other channel is deferred behind it +(§2). The pipeline (`.github/workflows/npm.yml`) builds four platform binaries +and one shared framework asset sidecar, publishes the single `spock` package +tokenlessly via OIDC trusted publishing, and verifies install-and-run on +macOS/Linux/Windows — `npx spock` works. First published version: +**`0.1.3`**. Three v0 simplifications departed from the original draft — a single bundling package instead of `optionalDependencies`, glibc instead of musl on Linux, and provenance attestation left off (it intermittently races the large tarball's publish, §5) — each forced by a concrete constraint and marked inline. The maintainer decisions that remain genuinely open are in §10. +Local framework acceptance assembles the exact 21-file package topology and +sidecar inventory. Release CI remains authoritative for the four real platform +artifacts and rejects any package above 25 MiB. The `0.5.0` dry run installed +and exercised that exact guarded tarball on macOS, Linux, and Windows after all +four native targets built successfully; verification against the first real +framework publish remains pending. + ## 0. Where this fits -Today `spock` runs one way: `git clone`, `cargo build`, run the binary out of -`target/`. Everything the language has earned — `fn`, the value tier, the -studio console, storage — is invisible to anyone who cannot build the repo. -Distribution is the crux that turns Spock from *a repository you build* into -*a tool you install and run*. It ships no new language surface; it makes the -existing surface reachable. +Before this pipeline, `spock` ran one way: `git clone`, `cargo build`, then run +the binary out of `target/`. Distribution is the crux that turned Spock from +*a repository you build* into *a tool you install and run*. The RFD 0022 +extension now distributes the framework host's Uhura assets beside that same +binary; it does not create a second CLI or release channel. Three doctrine anchors set the shape of the answer: @@ -37,21 +48,24 @@ And three facts about the build — established by reading the crates, not assumed — make this tractable and name the one hard part: 1. **One binary.** The workspace ships a single artifact, `spock` (from - `spock-cli`), at a single workspace version (`0.0.1`). + `spock-cli`), at the single `[workspace.package]` version. 2. **One native dependency.** The only C code in the shipped binary is `rusqlite`'s `bundled` SQLite, compiled from source via the `cc` crate. `reqwest` is a dev-dependency only, so the binary carries **no TLS/OpenSSL** — the usual Rust cross-compilation tar pit is absent. Every target needs only a C compiler, and one exists for every target we care about. -3. **The binary embeds a web app.** `rust-embed` bakes +3. **The binary embeds one web app and distributes another as a sidecar.** + `rust-embed` bakes `crates/spock-runtime/studio/dist` into the binary at compile time. That directory is git-ignored except a `.gitkeep`; it is produced by `pnpm build` (tsc + vite). A `cargo build` that skips the SPA build **still succeeds** and ships a binary whose `/~studio` console serves - nothing. This is the one genuine complication, and it is silent. + nothing. The framework host also serves Uhura Editor/Play and Wasm from a + shared executable-relative directory. Those assets are platform-independent + and belong once in the npm package, not once in every native binary. Two ownership facts are settled: the npm name **`spock` is owned** by the -project (today a reservation stub in `npm/`). The bare **`spock` crate name on +project and the real package lives in `npm/`. The bare **`spock` crate name on crates.io is taken** by an unrelated crate — which costs us nothing, since crates.io is out of scope for v0 and a future presence there would publish the `spock-lang` / `spock-runtime` / `spock-cli` names regardless. @@ -59,10 +73,11 @@ crates.io is out of scope for v0 and a future presence there would publish the ## 1. The shape of the answer Ship Spock as a **single npm package** — `spock` — that bundles a prebuilt -binary for every platform, with a ~20-line Node shim (`bin/spock.js`) that -resolves and execs the one matching the host's `os`+`arch` (§7). A user runs -`npx spock run app.spock` or `npm i -g spock`; no build step, no `postinstall`, -no network at install time. +binary for every platform, one shared Uhura web/Wasm sidecar, and a small Node +shim (`bin/spock.js`) that resolves and owns the binary matching the host's +`os`+`arch` (§7). A user runs `npx spock new demo`, retains +`npx spock run app.spock` as the language escape hatch, or installs globally; +there is no build step, `postinstall`, or network access at install time. Bundling every binary into one package — rather than the lighter esbuild-style `optionalDependencies` (one package per platform) — is a deliberate v0 choice @@ -70,20 +85,23 @@ forced by publishing. **npm Trusted Publishing (OIDC) is configured for the `spock` package only.** Each `@scope/spock-` package would need its own trusted-publisher config, which cannot be set until the package exists — a bootstrap that needs a one-time token. Bundling keeps v0 **tokenless** through -the one already-trusted package. At ~5–6 MB per binary (LTO + strip), four -platforms pack to an ~11 MB tarball — cheap enough that simplicity wins. +the one already-trusted package. The workflow's hard 25 MiB limit keeps the +all-platform trade-off explicit and makes package growth fail visibly. That +remains cheap enough for v0 that simplicity wins. `optionalDependencies` remains the documented end-state (§7), for when the platform packages are worth bootstrapping. -The binaries are produced by a **small hand-rolled GitHub Actions workflow**, +The package is produced by an **explicit hand-owned GitHub Actions workflow**, `.github/workflows/npm.yml` (the filename the trusted publisher is pinned to): -a 4-target build matrix (each job builds the studio SPA, then compiles the -binary) uploads four artifacts; a publish job assembles them into the `spock` -package and publishes it via OIDC; a verify job installs the published package -on macOS/Linux/Windows and runs it. No `dist`, no GitHub Release, no installers, -no Homebrew, no crates.io — for now. Because the binaries are built in CI -regardless, every one of those deferred channels is nearly free to add later -(§2). +an assets job builds and inventories Uhura once and publishes the exact raw +manifest SHA-256; a dependent 4-target build matrix (each job builds the Studio +SPA, then compiles that identity into the binary) uploads four native artifacts; +a publish job assembles them into the `spock` package and publishes it via OIDC; +a verify job installs either the exact guarded dry-run tarball or the published +registry package on macOS/Linux/Windows and runs it. No `dist`, no GitHub +Release, no installers, no Homebrew, no crates.io — for now. Because the +binaries are built in CI regardless, every one of those deferred channels is +nearly free to add later (§2). ## 2. Channels @@ -91,7 +109,7 @@ regardless, every one of those deferred channels is nearly free to add later |---|---|---| | **npm** — single `spock` package, all binaries bundled | **v0 (P0)** | The only user-facing channel. Name owned; publishes tokenlessly via OIDC. | | README "Install" section | **v0 (P0)** | `npx spock` / `npm i -g spock`; nothing is discoverable without it. | -| `spock init` scaffolding | **v0 (P1)** | The only real repo-less first-run gap (`spock run` already works). | +| `spock new` / `spock init` scaffolding | **framework (implemented)** | Create a canonical project or adopt existing sources without a checkout. | | GitHub Release binaries | **deferred** | The same CI binaries, also uploaded to a Release — a fallback download for node-less users. ~10 lines. | | `curl \| sh` + PowerShell installers | **deferred** | Generated by `dist` once we adopt it for the multi-channel pass. | | Homebrew tap (`gridaco/homebrew-tap`) | **deferred** | `brew install` for the mac/Linux slice; formula points at the Release binaries above. | @@ -108,14 +126,14 @@ design we have to unwind. ## 3. The target matrix -The `bundled` SQLite compiles wherever a C compiler exists; every target below -has one on its native runner. Four binaries, three build jobs — one macOS -runner produces both Apple targets by cross-compiling within the Apple SDK. +The `bundled` SQLite compiles wherever a C compiler exists. Four matrix jobs +produce four binaries; the two Apple jobs share the `macos-14` runner image, +and its SDK cross-compiles the Intel slice. | Rust triple | npm platform key | Runner | Note | |---|---|---|---| | `aarch64-apple-darwin` | `darwin-arm64` | `macos-14` | Native. | -| `x86_64-apple-darwin` | `darwin-x64` | `macos-14` | Same job: `rustup target add` + `--target`; Apple clang cross-compiles the bundled SQLite C to x86_64. | +| `x86_64-apple-darwin` | `darwin-x64` | `macos-14` | Apple clang cross-compiles the bundled SQLite C; `file` verifies the architecture and Rosetta executes version and language-check smokes. | | `x86_64-unknown-linux-gnu` | `linux-x64` | `ubuntu-22.04` | glibc, built on the older image for a wide glibc floor. **v0 ships gnu; musl is the follow-up (see below).** | | `x86_64-pc-windows-msvc` | `win32-x64` | `windows-2025` | Native MSVC `cl.exe`. | @@ -126,12 +144,14 @@ shim branches only on `os`+`arch`, never on which package installed, so there is hosts the one `linux-x64` binary runs on. v0 ships **glibc** built on `ubuntu-22.04` (glibc 2.35) to isolate CI variables for the first verified release; it covers Ubuntu/Debian/Fedora/WSL and most non-Alpine containers. -**Static musl** — which additionally runs on Alpine/musl hosts — is a one-line -matrix change (target + `cargo-zigbuild` or `musl-tools`) once the pipeline is -proven; because `rusqlite`-on-musl has a history of subtle segfault reports, -that switch lands with a **CI smoke test** on the musl artifact (`spock run` a +**Static musl** — which additionally runs on Alpine/musl hosts — requires a +separate target and linker toolchain (`cargo-zigbuild` or `musl-tools`). Because +`rusqlite`-on-musl has a history of subtle segfault reports, that addition lands +with a **CI smoke test** on the musl artifact (`spock run` a fixture → hit `/graphql/v1` → assert 200) before it can publish. The build matrix already smoke-tests each native-arch binary with `spock check`. +The npm shim detects a reported non-glibc Node runtime before launch and gives +an explicit Alpine/musl error instead of exposing a native-loader failure. **`[profile.release]`:** `lto = true`, `codegen-units = 1`, `strip = "symbols"`, `panic = "abort"`. **Keep `opt-level = 3`** (the release @@ -162,7 +182,7 @@ this has a single, simple answer: **every build job runs `pnpm build` before SPA. The SPA output is platform-independent, so building it once and fanning it out -via `upload-artifact` would also work — but with only three jobs, the per-job +via `upload-artifact` would also work — but with only four target jobs, the per-job build is simpler (no artifact plumbing) *and* it validates that `pnpm build` succeeds on macOS and Windows, which it has never been exercised on (the studio has only ever been built on one Mac). That validation is worth the few extra @@ -172,53 +192,87 @@ seconds. The non-empty guard makes the silent failure a hard one. Cargo `include` plus a check-only `build.rs` — is deferred with crates.io itself; it is recorded in §9/D5 so it isn't re-derived later.) +The Uhura assets have a different ownership shape from embedded Studio. One +Linux `assets` job initializes the pinned Uhura submodule, uses pnpm 10.11.0, +runs the complete `uhura/web` check/build gate, builds the lockfile-matched +`wasm-bindgen` web bundle, and +assembles `share/spock/uhura/{web,wasm}` once. A +`spock-asset-sidecar/1` manifest records the root and Uhura commits, every +spoken framework/Uhura protocol, and a sorted SHA-256/size inventory of every +asset. Manifest paths use a portable ASCII segment grammar, reject Windows +device names and case-folded collisions, and sort by UTF-8 bytes (equivalent to +ASCII byte order here). Publish and installed-package verification both +recheck the exact inventory; the four binary jobs never duplicate or rebuild +the sidecar. + ## 5. The release pipeline -**Triggers:** `workflow_dispatch` (with `version` / `dist_tag` / `dry_run` -inputs, for dry runs and prereleases under a dist-tag) and a pushed `vX.Y.Z` -tag (publishes `latest`). One hand-written `.github/workflows/npm.yml` that we -own — the filename is load-bearing, because the npm trusted-publisher config is -pinned to it. +**Triggers:** `workflow_dispatch` (with optional `version` assertion plus +`dist_tag` / `dry_run`) and a pushed semver tag. A dispatch without `version` +derives it from `[workspace.package]`; any supplied value and every pushed tag +must equal that source of truth. Stable tags publish `latest`, while tags with +a prerelease suffix publish `next`. One hand-written +`.github/workflows/npm.yml` is owned here — the filename is load-bearing, +because the npm trusted-publisher config is pinned to it. ``` dispatch / tag vX.Y.Z - └─ build (matrix: macos-14 → darwin-arm64 + darwin-x64; ubuntu-22.04 → linux-x64; windows-2025 → win32-x64) + ├─ assets (once, ubuntu-22.04) + │ ├─ initialize recursive submodules + │ ├─ pnpm 10.11.0 install + full Uhura web/provider check and build + │ ├─ build Uhura Wasm with the lockfile-exact wasm-bindgen CLI + │ ├─ guard web routes, Wasm artifacts, hashes, sizes, and protocols + │ └─ upload-artifact: framework-assets + ├─ build (matrix: macos-14 → darwin-arm64 + darwin-x64; ubuntu-22.04 → linux-x64; windows-2025 → win32-x64) │ ├─ pnpm install + build studio (§4) │ ├─ guard: studio dist/index.html non-empty - │ ├─ cargo build --release --target … (rust-embed bakes the SPA) - │ ├─ smoke-test native-arch binaries (spock --version && spock check) + │ ├─ cargo build --locked --release --target … (rust-embed bakes the SPA) + │ ├─ smoke native binaries; execute macOS x64 through Rosetta │ └─ upload-artifact: bin- └─ publish (id-token: write) - │ ├─ download all bin-* artifacts → assemble npm/binaries// (+chmod +x on unix) - │ ├─ guard: all four platforms present + │ ├─ download bin-* and framework-assets + │ ├─ assemble npm/binaries// (chmod 0755 on Unix) + │ ├─ guard: all four platforms and the sidecar are present │ ├─ stamp the resolved version into package.json - │ └─ npm publish (OIDC, tokenless; --dry-run when the dry_run input is set) - └─ verify (skipped on dry runs; matrix: macos-14, ubuntu-22.04, windows-2025) - ├─ npm i -g spock@ (retry for registry propagation) + │ ├─ npm pack: exact file set, exact 0755 executables, tarball ≤ 25 MiB + │ ├─ upload the guarded tarball as a one-day workflow artifact + │ └─ npm publish the same guarded .tgz (OIDC, tokenless; --dry-run when requested) + └─ verify (matrix: macos-14, ubuntu-22.04, windows-2025) + ├─ dry run: install the exact guarded tarball workflow artifact + ├─ publish: npm i -g spock@ (retry for registry propagation) + ├─ assert npm-visible and full Cargo/binary versions independently ├─ spock --version && spock check - └─ [unix] spock run + curl /~studio → proves the embedded console is served + ├─ verify the installed sidecar manifest and file hashes + ├─ [unix] spock run + curl /~studio → proves the embedded console is served + └─ [all OSes] spock new + start + route probes → proves Editor/Play/Wasm + and the framework status/environment protocols are served ``` **Publishing is tokenless.** The publish job carries `id-token: write` and -upgrades to the latest npm (Trusted Publishing needs ≥ 11.5.1); `npm publish` +installs pinned npm 11.6.2 (Trusted Publishing needs ≥ 11.5.1); `npm publish` then authenticates through the OIDC trusted publisher configured for `spock` — -no `NODE_AUTH_TOKEN`. **Provenance is disabled** (`--no-provenance`): with an -~11 MB tarball the attestation step intermittently races the package PUT and -the registry returns a false `E400 "cannot publish over previously published -version"` — which *burns* the version (it's reserved but never served, and can't -be reused). Publishing to a `next` dist-tag happened to dodge it while `latest` -hit it repeatedly; disabling provenance removed the race entirely. Tokenless -auth is independent of provenance, so nothing else changes. A `dry_run` input -gates real publishes so the cross-platform build can be proven without spending -an npm version; prereleases go out under a `next` dist-tag so `latest` -only ever moves on a real cut. +no `NODE_AUTH_TOKEN`. **Provenance is disabled** (`--no-provenance`): with the +original ~11 MB binary-only tarball, the attestation step intermittently races +the package PUT and the registry returns a false `E400 "cannot publish over +previously published version"` — which *burns* the version (it's reserved but +never served, and can't be reused). Publishing to a `next` dist-tag happened to +dodge it while `latest` hit it repeatedly; disabling provenance removed the +race entirely. Tokenless auth is independent of provenance, so nothing else +changes. A `dry_run` input gates real publishes while the exact guarded tarball +is installed and exercised on the full macOS/Linux/Windows verification matrix +without spending an npm version. Prereleases go out under a `next` dist-tag so +`latest` only ever moves on a real cut. Both dry and real `npm publish` receive +that same guarded `.tgz` as their package argument; neither branch silently +repacks the source directory. **Why hand-rolled, not `dist`.** `dist`'s value is the matrix + C-toolchain provisioning + installers + Homebrew formula + GitHub Release, generated together. In npm-only mode we use only the first two, publish a bespoke single-package layout it doesn't model, and would be fighting its generated -workflow to *not* emit the outputs we don't want. The hand-rolled surface is -~200 lines of YAML over `actions/setup-node`, `pnpm/action-setup`, +workflow to *not* emit the outputs we don't want. The hand-owned surface is now +about 500 lines because it includes framework-asset assembly, exact package +guards, and installed cross-platform route smokes, over `actions/setup-node`, +`pnpm/action-setup`, `dtolnay/rust-toolchain`, and `actions/{upload,download}-artifact`. `dist` becomes the right borrow **later**, when installers + Homebrew are added, because then it regenerates all of them from one config; the deferred-channel @@ -228,7 +282,12 @@ style isn't Conventional Commits, so its auto-changelog would be noise). ## 6. Versioning - **Single source of truth:** `[workspace.package] version`. The git tag - mirrors it; every npm package version equals it, exact-pinned. + mirrors it exactly. Cargo's parsed version is the SemVer authority; release CI + does not maintain a second regex. The workflow removes `+build` metadata from + npm's registry-visible version because npm's version-stamping command does + not preserve it, while the binary retains the complete Cargo version. CI also + uses that metadata-free spelling for prerelease channel selection, so a + hyphen inside build metadata cannot select `next`. - **Trigger:** bump the version → commit → `git tag vX.Y.Z && git push --tags`. - **First public release is `0.1.3`.** `0.0.1` was a name-reservation placeholder; the first real distributed release is `0.1.x`, staying honestly @@ -244,15 +303,16 @@ style isn't Conventional Commits, so its auto-changelog would be noise). ## 7. The npm package -v0 is a **single package** — `spock` — carrying the shim and all four platform -binaries. Zero runtime dependencies, no `postinstall`, no network at install. +v0 is a **single package** — `spock` — carrying the shim, all four platform +binaries, and one shared Uhura sidecar. Zero runtime dependencies, no +`postinstall`, no network at install. ```jsonc -// npm/package.json (the reservation stub, grown up) +// npm/package.json (the former reservation, now the real distribution) { - "name": "spock", "version": "0.1.0", + "name": "spock", "version": "", "bin": { "spock": "bin/spock.js" }, - "files": ["bin/", "binaries/"], + "files": ["bin/", "binaries/", "share/", "THIRD_PARTY_NOTICES.md"], "publishConfig": { "access": "public", "provenance": false } } ``` @@ -261,15 +321,25 @@ The tree published to npm: ``` spock/ + package.json npm metadata and `spock` bin declaration + README.md installed-package usage + LICENSE project license + THIRD_PARTY_NOTICES.md bundled Uhura and Wasm notices bin/spock.js the shim (committed) binaries/darwin-arm64/spock ┐ binaries/darwin-x64/spock │ assembled in CI from the build artifacts binaries/linux-x64/spock │ (git-ignored; never committed) binaries/win32-x64/spock.exe ┘ + share/spock/uhura/ + manifest.json executable-bound integrity + compatibility + web/ Uhura Editor and Play browser application + wasm/ wasm-bindgen web module and Wasm binary ``` The shim resolves the host's `os`+`arch`, checks the bundled binary exists, and -execs it with argv verbatim, propagating the child's exit code. Detecting +spawns it with argv verbatim. It forwards terminal signals and propagates the +child's exit status so long-lived framework commands retain one owner. +Detecting `os`+`arch` at runtime (rather than trusting which package installed) is the portable path across npm/pnpm/yarn/bun: @@ -278,24 +348,32 @@ const key = `${process.platform}-${process.arch}`; // e.g. darwin-a const exe = process.platform === "win32" ? "spock.exe" : "spock"; const bin = path.join(__dirname, "..", "binaries", key, exe); if (!fs.existsSync(bin)) { /* clear error, exit 1 */ } -execFileSync(bin, process.argv.slice(2), { stdio: "inherit" }); +const child = spawn(bin, process.argv.slice(2), { stdio: "inherit" }); +// Forward SIGINT/SIGTERM/SIGHUP; translate close into the shim's exit status. ``` -The unix binaries are `chmod +x`'d in CI before packing (`upload-artifact` -drops the exec bit); npm preserves the mode into the tarball, so the installed -binary is executable. The whole package is ~11 MB packed / ~23 MB unpacked. - -**Version.** CI stamps `package.json` to the version resolved from the tag or -dispatch input, so the npm version always equals the Rust build it wraps. The -`spock` reservation stub is grown in place (`main`/`index.js` dropped — a CLI -wrapper needs only `bin`); the unrelated legacy 2014 `spock` versions are +The committed shim is mode `0755`, and CI restores that exact mode on the Unix +native binaries (`upload-artifact` drops native executable bits); npm preserves +those modes in the tarball. Release CI rejects any missing or unexpected packed +path, checks the shim plus all three Unix native binaries are exactly `0755`, +and measures the actual artifact against the framework's 25 MiB budget. + +**Version.** CI derives the version from `[workspace.package]`; a tag or an +optional dispatch value is an exact assertion, never an independent source. It +stamps `package.json` with the Cargo version minus optional `+build` metadata, +which npm does not preserve in its registry version, and separately verifies +that the installed binary reports the complete Cargo version. The former +`spock` reservation stub was grown in place +(`main`/`index.js` dropped — a CLI wrapper needs only `bin`); the unrelated +legacy 2014 `spock` versions are `npm deprecate`d so a range-less `npm i spock` can't resolve an old `0.3.x`. **The `optionalDependencies` end-state (deferred).** The lighter, canonical layout (esbuild, `@swc/core`, `@biomejs/biome`, oxlint) is a thin `spock` wrapper listing one prebuilt-binary package per platform as exact-pinned `optionalDependencies` (each with `os`/`cpu`), so a user downloads only their -platform's binary (~5 MB, not ~11 MB). v0 does **not** use it: each +platform's native binary plus the shared sidecar instead of every platform +binary. v0 does **not** use it: each `@scope/spock-` package needs its own trusted-publisher config, which can't be set until the package exists — a bootstrap requiring a one-time token, against the tokenless goal. When the download-size saving is worth the @@ -306,43 +384,43 @@ it unchanged. ## 8. First-run experience -A distributed user has the binary and their own `.spock` file, no repo. -`spock run app.spock` already works standalone (disposable state, embedded -SQLite, self-served studio), so the onboarding gap is small — one thing: - -- **`spock init [name]` (P1)** — write a minimal starter `.spock` (an - `include_str!`'d template) so a first-time user has something to `run` - immediately. -- `spock run --watch` is the live "executable PRD" demo (roadmap track 9); - independently valuable and pairs well here, but P2 for distribution. +A distributed user has the binary and no checkout. `spock run app.spock` +remains the standalone language escape hatch. RFD 0022 replaces the earlier +undifferentiated `spock init [name]` sketch with two project commands: +`spock new NAME` creates the canonical full-stack project (or +`--backend-only`), while `spock init [path]` adopts existing sources without +moving or overwriting them. Canonical scaffold bytes stay embedded in the +binary; the sidecar is runtime browser machinery, not a template dependency. ## 9. Decisions | # | Decision | Recommendation | Trade-off | |---|---|---|---| -| D1 | Orchestrator | **Hand-rolled `npm.yml`** for npm-only; adopt `dist` later when installers + brew are added | ~200 lines we own vs bending a generator; `dist` re-enters when its unused outputs become wanted. | -| D2 | npm layout | **Single package, all binaries bundled** (not `optionalDependencies`) | ~11 MB download; but tokenless through the one trusted `spock` package. `optionalDependencies` deferred until worth a bootstrap token (§7). | -| D3 | Linux binary | **glibc for v0** (`ubuntu-22.04`); static-musl as the follow-up | glibc covers most hosts and isolates first-release CI variables; musl (Alpine) is a one-line matrix change + smoke test. | +| D1 | Orchestrator | **Hand-rolled `npm.yml`** for npm-only; adopt `dist` later when installers + brew are added | About 500 explicit lines including framework/package verification; `dist` re-enters when its currently unused outputs become wanted. | +| D2 | npm layout | **Single package, all binaries bundled** (not `optionalDependencies`) | Hard 25 MiB release gate; tokenless through the one trusted `spock` package. `optionalDependencies` is deferred until worth a bootstrap token (§7). | +| D3 | Linux binary | **glibc for v0** (`ubuntu-22.04`); static-musl as the follow-up | glibc covers most hosts and isolates first-release CI variables; musl needs another target, linker toolchain, and runtime smoke. | | D4 | Studio prebuild | **plain `pnpm build` step per job + non-empty guard** | Rebuilds the SPA in each build job (cheap) and validates the mac/Windows build. | | D5 | Studio for crates.io | *(deferred)* `include = ["studio/dist/**"]` + check-only `build.rs` | Recorded so it isn't re-derived when crates.io is picked up. | | D6 | npm name | **`spock` (owned)**; no scope needed at v0 (single package) | A scope (`@gridaco`) only becomes relevant if `optionalDependencies` is adopted. | -| D7 | First version | **`0.1.0`** | Signals a real release; keeps `1.0` for a promise not yet made. | +| D7 | First successful version | **`0.1.3`** | `0.1.0`–`0.1.2` were burned by the provenance race; `1.0` remains a future stability promise. | | D8 | Changelog | **Hand-curated `CHANGELOG.md`** | Manual, but the commit style defeats auto-generators. | | D9 | crates.io | **deferred** (bare `spock` taken; irrelevant at v0) | A future presence publishes under `spock-*`. | | D10 | Housekeeping | **Delete the stray root `now` file; keep `studio/dist/.gitkeep`** | Zero cost; the `.gitkeep` keeps the rust-embed folder present on fresh checkouts. | -| D11 | First-run UX | **Add `spock init` (P1)**; `--watch` P2 | Closes the only repo-less onboarding gap. | +| D11 | First-run UX | **`spock new` creates; `spock init` adopts** (RFD 0022) | Closes the repo-less onboarding gap without conflating creation and adoption. | +| D12 | Framework assets | **One shared Uhura web/Wasm sidecar plus an executable-bound integrity manifest** | Avoids four copies in native binaries; serializes asset and native builds so every binary carries the exact manifest SHA-256 and rejects another executable-relative tree. | ## 10. Open questions for the maintainer -1. **Switch Linux to static-musl now, or ship glibc for v0?** v0 ships glibc - (D3); flipping to musl (adds Alpine/musl hosts) is a one-line matrix change - plus a smoke test whenever an Alpine user appears. +1. **When should a musl artifact be added?** v0 ships glibc (D3) and reports + the unsupported runtime clearly; add and smoke-test musl when Alpine demand + justifies another Linux target. 2. **macOS `universal2` (one fat binary via `lipo`) vs two thin slices?** Recommend two thin slices for v0; `universal2` only if a future single-file download UX wants it. -3. **Adopt `optionalDependencies` later?** Worth it once the ~11 MB download - matters more than the one-time token needed to bootstrap the platform - packages (§7); until then the single package is simpler and stays tokenless. +3. **Adopt `optionalDependencies` later?** Worth it once the measured +all-platform package size matters more than the one-time token needed to +bootstrap the platform packages (§7); until then the single package is simpler +and stays tokenless. 4. **When do the deferred channels turn on?** Recommend adding the GitHub-Release upload (near-free) the first time a node-less user needs a binary, and the `dist`-driven installers + Homebrew pass once there's traction to justify it. @@ -351,7 +429,8 @@ SQLite, self-served studio), so the onboarding gap is small — one thing: **P0 — `npm i -g spock` (or `npx spock`) and run it. _Done._** - [x] Housekeeping: `git rm now`; `studio/dist/.gitkeep` kept committed. -- [x] Add the `[profile.release]` levers (§3); keep `opt-level = 3`. Binary ~5 MB. +- [x] Add the `[profile.release]` levers (§3); keep `opt-level = 3` and enforce + the package-size budget in release CI. - [x] Trusted publishing configured for `spock` (OIDC; no token). _(maintainer)_ - [x] Grow `npm/` into the `spock` package + `bin/spock.js` shim (§7). - [x] Write `.github/workflows/npm.yml`: the 4-target build matrix (pnpm build @@ -365,12 +444,24 @@ SQLite, self-served studio), so the onboarding gap is small — one thing: `npx spock@latest` on a dev Mac renders `/~studio` and serves `/~contract`. - [x] Add the README "Install" section (`npx spock` primary). - [x] Write `CHANGELOG.md`. +- [x] Extend the package with one shared Uhura web/Wasm sidecar; build it once, + record commits/protocols/hashes/sizes, bind its manifest SHA-256 into all + four binaries, and enforce the 25 MiB packed gate. +- [x] Run the first framework release dry run: the exact guarded `0.5.0` + tarball passed the four-target build and macOS/Linux/Windows + installed-package verification, including framework routes and sidecar + ([run 29379605382](https://github.com/gridaco/spock/actions/runs/29379605382)). + +**Framework release follow-through.** +- [ ] Repeat the full-matrix verification against the first real framework + publish. **P1 — smoother first run.** - [ ] Re-enable provenance once the tarball is smaller (optionalDependencies) or via a post-attestation-`E400`-tolerant publish retry (§5, §12). - [ ] Switch `linux-x64` to static-musl (adds Alpine) + its smoke test (D3). -- [ ] Add `spock init [name]` (embed a starter via `include_str!`). +- [x] Refine `spock init [name]` into RFD 0022's `spock new` and adopting + `spock init` commands with embedded canonical scaffolds. - [ ] Add the `aarch64-unknown-linux-*` and `aarch64-pc-windows-msvc` targets. **Later — the deferred channels (each cheap because the binaries already exist).** @@ -387,32 +478,50 @@ Honest accounting, framed for a low-maintenance prototype. - **Silent empty studio (medium unguarded → low with the guard).** The signature failure: cargo build succeeds, the console is blank. *Mitigation:* - the §4 non-empty `index.html` guard makes it a hard failure; P0 verification - renders `/~studio` on macOS and Windows. + the §4 non-empty `index.html` guard runs in every build job; installed-package + verification serves `/~studio` on macOS and Linux. +- **Missing or mismatched framework sidecar (medium unguarded → low with the + executable binding).** A native binary alone cannot serve Uhura Editor/Play. + The asset job validates required route literals and Wasm magic, records exact + commits and protocols beside every hashed file, then emits the SHA-256 of the + raw manifest. Every distribution binary captures that value and checks it + before parsing the executable-relative manifest; the manifest inventory is + then checked against both the filesystem and the immutable bytes that will be + served. This detects corruption or coherent sidecar replacement while the + executable remains trusted. It is not a package signature and cannot defend + against replacement of both binary and sidecar or compromise of release CI. + Explicit paired source/test overrides are intentionally outside this package + identity boundary. `npm pack` additionally proves the exact tarball tree, + both publish branches consume that same file, and cross-platform verification + installs it before exercising framework routes. - **npm publish atomicity (low).** The single-package design publishes one package per release, so there is no partial/half-released state — the whole binary set is in one tarball. The residual rule is npm's own: a version can't - be reused, so never re-tag a version. *Mitigation:* the `dry_run` gate proves - the build before any real publish; prereleases use a `next` dist-tag. -- **Provenance is off (accepted; supply-chain nicety deferred).** With an - ~11 MB tarball the provenance attestation intermittently races the package - PUT and burns the version (§5); it cost `0.1.0`–`0.1.2` before we disabled it. + be reused, so never re-tag a version. *Mitigation:* the `dry_run` gate installs + and exercises the exact guarded tarball on every supported OS before any real + publish; prereleases use a `next` dist-tag. +- **Provenance is off (accepted; supply-chain nicety deferred).** With the + original ~11 MB binary-only tarball, the provenance attestation intermittently + races the package PUT and burns the version (§5); it cost `0.1.0`–`0.1.2` + before we disabled it. Tokenless OIDC auth is unaffected; only the signed SLSA attestation is missing. *Re-enable path (P1):* shrink the per-publish tarball via the `optionalDependencies` split (§7), or add a bounded publish retry that treats a post-attestation `E400` as success-if-the-version-appears. - **Linux is glibc-only at v0 (accepted).** Alpine/musl hosts aren't served - until the musl follow-up (D3). *Mitigation:* covers the large majority of the - audience's Linux now; musl is a one-line matrix change + smoke test. -- **Windows studio build (now proven).** The SPA build had only run on one Mac; - the build matrix exercises `pnpm build` on Windows and macOS every release, - and the verify job renders `/~studio` live on macOS/Linux. + until the musl follow-up (D3). *Mitigation:* the npm shim diagnoses the libc + mismatch directly and points users to a GNU-libc host or a source build. +- **Cross-platform browser assets (now gated).** The build matrix exercises the + embedded Studio build on Windows and macOS. Installed-package verification + serves Studio on Unix and probes framework Editor, Play, Wasm, status, and + environment routes on macOS, Linux, and Windows. - **npm-only means Node is required (accepted).** No binary for node-less environments at v0. *Mitigation:* the deferred GitHub-Release upload is a ten-line addition the day it's needed. **Net:** after P0, a release is *bump version → tag → push*, and one tag builds -four binaries and publishes one npm package that bundles them — verified live on -macOS, Linux, and Windows. The single hand-owned piece — the ~20-line shim — is -exactly the place where every borrowed default was the wrong fit, and the -pipeline stays a strict subset that the deferred channels extend without rework. +four binaries plus one shared framework sidecar and publishes one npm package +that bundles them — verified live on macOS, Linux, and Windows. The small +hand-owned shim and sidecar verifier are the package-specific boundaries; the +pipeline stays a strict subset that the deferred channels extend without +rework. diff --git a/docs/rfd/0022-spock-framework.md b/docs/rfd/0022-spock-framework.md index cd85acc..d7fb45d 100644 --- a/docs/rfd/0022-spock-framework.md +++ b/docs/rfd/0022-spock-framework.md @@ -1,21 +1,20 @@ # RFD 0022 — Spock as a framework: one project, one command, two languages -Status: **study draft and discussion record.** This RFD records the current -working direction for a unified Spock project and toolchain. It proposes no -implementation and does not merge Spock and Uhura semantics. Exact manifest -syntax, route allocation, packaging, and development-state behavior remain -open. The state problem is studied separately in -[RFD 0023](0023-development-state-reload.md) and must be decided before the -combined development host is implemented. +Status: **accepted and implemented for the initial framework host** +(2026-07-15). Spock and Uhura semantics remain separate. The unresolved +long-term state problem stays in +[RFD 0023](0023-development-state-reload.md); the safe +client-live/backend-pinned policy in Section 12.1 is the implemented contract, +not a placeholder auto-migration scheme. ## 0. The question -The installed `spock` command currently takes a `.spock` file as its unit of -work. The Spock–Uhura composition proof has a different unit: +At decision time, the installed `spock` command took a `.spock` file as its +unit of work. The Spock–Uhura composition proof had a different unit: 1. one Spock authority program; 2. one optional Uhura client project; and -3. composition knowledge currently carried by a shell script, two process +3. composition knowledge then carried by a shell script, two process lifecycles, and duplicated port configuration. The proof is real: [`scripts/spock-uhura.sh`](../../scripts/spock-uhura.sh) @@ -35,10 +34,10 @@ provider repeats the Spock port in `uhura.toml`. - absorbing Uhura means absorbing discovery, distribution, lifecycle, diagnostics, linking, and hosting — **not semantic ownership**. -This document records that direction before either runtime is reshaped around -it. It deliberately does not solve development database reload; pretending -that question is a host implementation detail would bake an accidental answer -into the framework boundary. RFD 0023 owns it. +This document recorded that direction before either runtime was reshaped and +now governs the resulting host. It deliberately does not solve development +database reload; pretending that question is a host implementation detail +would bake an accidental answer into the framework boundary. RFD 0023 owns it. ### In scope @@ -82,8 +81,7 @@ The current repository boundary also remains intentional: as a git submodule; - Uhura remains independently buildable and testable; - `spock-runtime` does not acquire UI-session or renderer behavior; -- a combined host should consume a proposed versioned Uhura host/library - boundary; and +- the combined host consumes the versioned `uhura-host` library boundary; and - the standalone `uhura` command remains useful to contributors and for isolated language work even though it is not the public framework front door. @@ -109,8 +107,9 @@ The name “Spock” now appears at several layers. Use these terms precisely: - **Project generation** — one coherent checked backend artifact, host routing table, binding to backend development state, and, when a client is configured, the matching integrated Uhura Play artifact. Uhura Editor may - publish a newer static read model with explicit freshness independently; - RFD 0023 defines the backend-state and activation parts of this term. + publish a newer static read model with explicit freshness independently. In + the first implementation, its backend generation is pinned for the process + lifetime; RFD 0023 studies later activation models. This vocabulary prevents two opposite mistakes: shrinking Spock back to only the language when discussing the installed product, and growing @@ -118,27 +117,28 @@ the language when discussing the installed product, and growing ## 3. The project is the new unit of work -The recommended generated shape is: +The exact minimal full-stack starter generated by `spock new` is: ```text my-app/ ├── spock.toml ├── backend/ -│ ├── app.spock -│ └── seed/ # optional file(...) assets -└── client/ # optional; shown here as the full-stack shape +│ └── app.spock +└── client/ # omitted by --backend-only ├── uhura.toml - ├── uhura.lock - ├── app/ - ├── components/ - ├── surfaces/ - ├── ports/ - ├── providers/ - ├── fixtures/ - ├── catalog/ - └── styles/ + ├── app/home/ + │ ├── page.uhura + │ └── page.examples.uhura + ├── catalog/base.toml + └── fixtures/ + ├── empty.toml + └── scripts/empty.toml ``` +Projects add optional seed assets and richer Uhura directories such as +components, surfaces, ports, providers, and styles only when the application +needs them. The initial starter does not invent empty structure or a lockfile. + `app.spock` is the conventional backend name, not `main.spock`: - it already appears in examples, command tests, and the composition proof; @@ -162,8 +162,8 @@ entry = "app.spock" root = "client" ``` -This exact spelling is provisional. The responsibilities are the important -part: +This spelling is the version-1 schema. Unknown keys are errors so a misspelled +root cannot silently select a different project. The responsibilities are: 1. `spock.toml` is required for project/framework commands. 2. Exactly one Spock backend entry is required in the first version. @@ -197,13 +197,14 @@ The user guidance can be simple: Do not scaffold a fake table merely to satisfy GraphQL. An empty authority means “no authoritative capabilities,” not “invent a placeholder data model.” The host should still be able to serve project health and the configured -client; authority routes may be absent or expose a deliberately empty -contract according to a later route decision. +client. Contract metadata remains present, while GraphQL is absent with a +structured 404 because an empty authority advertises no GraphQL capability. -This is not current behavior end to end. An empty source can compile and the -SQLite engine can materialize zero contract tables, but the eager GraphQL -builder rejects a `Query` with no fields. “Empty authority boots” is therefore -an implementation prerequisite for the framework, not a claim about v0. +This is current framework behavior. The Spock runtime recognizes that an empty +authority has no GraphQL operation-root field and skips schema construction; +the framework environment advertises `graphql_path: null`, while the combined +fallback returns a structured 404 for `/graphql/v1`. No fake query field or +placeholder table is introduced. ## 5. The command ecosystem @@ -214,7 +215,7 @@ The recommended public surface is: | `spock new ` | new project | Create the selected canonical project template, always including the manifest and required backend. | | `spock init [path]` | existing directory | Adopt existing sources without moving or overwriting them. | | `spock check [path]` | project | Check backend, client, manifest, and every currently provable provider/link contract as one result. | -| `spock dev [path]` | project | Observe saved source and serve coherent last-good project generations. Exact backend state behavior is RFD 0023. | +| `spock dev [path]` | project | Rebuild client source live; observe backend changes as restart-required while keeping the active backend pinned. | | `spock start [path]` | project | Check once and serve one fixed combined generation with no source watcher. | | `spock run ` | Spock program | Preserve the standalone authority/file escape hatch. | | `spock build`, `spock gen` | artifact | Preserve existing language artifacts; project-aware variants are a separate design. | @@ -231,17 +232,17 @@ is ambiguous. Adopting an Uhura-only directory creates the required empty backend entry and points `spock.toml` at it; it does not invent a placeholder table or relocate the Uhura project. -RFD 0020 recorded an unimplemented `spock init [name]` as the general -first-run gap. If this RFD is accepted, it refines that spelling: `new` creates -a named project; `init` adopts a directory. The distribution requirement -remains intact. +RFD 0020 recorded `spock init [name]` as the original general first-run gap. +This accepted RFD refined that spelling in the implementation: `new` creates a +named project; `init` adopts a directory. The distribution requirement remains +intact. ### 5.2 `dev` and `start` are different `spock dev` means saved-source observation, current diagnostics, coherent candidate ordering, last-good retention, and browser generation events. It -must not promise that authoritative state survives a structural edit until -RFD 0023 defines when and how that is true. +keeps the active backend and its state pinned after startup. A structural +backend edit is reported but never applied until an explicit restart. `spock start` means a fixed generation: resolve, check, construct, bind, and serve without watching or automatic replacement. The name does not imply that @@ -254,9 +255,12 @@ the current `spock gen` forms remain valuable for language development, CI fixtures, backend-only experiments, and compatibility with the published command. -Directory or omitted targets should select project mode; an explicit -`.spock` target should retain file mode. Whether project discovery walks up -through parents or only accepts the current/explicit root remains open. +Directory or omitted targets select project mode; an explicit `.spock` target +retains file mode. Project discovery starts at the explicit directory or the +current directory and walks upward to the nearest `spock.toml`. It never walks +past a discovered nested project. Every manifest path is relative, rejects +absolute paths, prefixes, and `..`, and must resolve inside the canonical +project root; a symlink that resolves outside the root is an error. Uhura-only expert operations such as formatting and deterministic traces need a later namespace decision. `spock client fmt` and `spock client trace` are @@ -292,7 +296,7 @@ The dependency/ownership direction is: spock-cli ├── spock-project ── manifest, discovery, paths, scaffolding └── spock-host ───── project generations, routes, listener, lifecycle - ├── spock-project ── project/snapshot types and path rules + ├── spock-project ── validated layout/config types and path rules ├── spock-runtime ── one authority contract/database/API router │ └── spock-lang └── uhura-host ───── reusable Editor/Play host service @@ -302,13 +306,12 @@ uhura-cli ── uhura-host # standalone contributor/subsystem command ### `spock-project` -Owns `spock.toml` parsing and version diagnostics, root discovery, normalized -paths, the immutable project-input/snapshot data model, scaffold templates, -and adoption planning. Each language subsystem remains responsible for -enumerating and coherently capturing its semantic inputs through its library -boundary; `spock-project` must not duplicate Uhura's established -project-capture rules. It owns no filesystem watcher, HTTP listener, or live -database. +Owns `spock.toml` parsing and version diagnostics, the validated project +layout/configuration model, root discovery, portable paths, scaffold templates, +and adoption planning. Each language subsystem owns its captured semantic-input +type and coherent capture rules; `spock-project` must not duplicate Uhura's +established project-capture rules. It owns no filesystem watcher, HTTP listener, +or live database. ### `spock-host` @@ -318,7 +321,7 @@ coordination needed by both `dev` and `start`. For `dev`, that includes filesystem observation, monotonic source-revision assignment, and coherent cross-subsystem capture orchestration; the language subsystems still enumerate and capture their own semantic inputs. The host consumes `spock-project`'s -project/snapshot types and normalized path rules; the CLI also consumes that +validated layout/configuration model and normalized path rules; the CLI also consumes that crate directly for `new`, `init`, and project discovery. `spock-host` is clearer than `spock-dev-server`: it serves fixed generations too. It is clearer than `spock-framework`, which names the product concept rather than a concrete @@ -330,39 +333,37 @@ runtime or in a later focused crate once their boundary is understood. ### `spock-runtime` -Continues to implement one Spock authority generation. It should expose +Continues to implement one Spock authority generation. It exposes constructible service/router boundaries, but it must not become the project manifest reader, Uhura host, or master process supervisor. ### `uhura-host` -Should be extracted inside the submodule from the current -`uhura-cli::cmd::dev` behavior. It owns reusable Uhura Editor/Play state and -routes without binding its own mandatory listener or returning CLI-specific -exit codes. `uhura-cli` then remains a thin standalone entrypoint over it. - -The logical dependency is settled more easily than the physical Cargo and -release linkage. A root crate path-depending directly on an uninitialized -submodule would end the current “core checkout builds without Uhura” property, -even if the dependency were feature-gated: Cargo still resolves path -manifests. Preserving that property requires a real package/manifest boundary, -such as a published internal Uhura crate, a vendored/generated artifact, or a -separate full-framework manifest/build that is used only when the submodule is -present. Requiring the submodule for the distributed binary is another -explicit option. This choice remains open; it must not be hidden inside the -crate diagram. +Is extracted inside the submodule from the original `uhura-cli::cmd::dev` +behavior. It owns reusable Uhura Editor/Play state and routes without binding +its own mandatory listener or returning CLI-specific exit codes. `uhura-cli` +remains the standalone listener and contributor entrypoint over it. + +The root consumes `uhura-host` through a direct path dependency into the +initialized submodule. This deliberately ends the “complete public binary +builds from a core-only checkout” property: framework source builds and npm CI +must initialize submodules. Uhura keeps its separate workspace and lockfile. ## 7. One host and one origin -One public listener and one browser origin are the preferred direction. An -illustrative, non-final route allocation is: +One public listener and one browser origin are required. The allocation is: ```text -/ Uhura Play, Editor, or project landing (open) -/~editor Uhura Editor -/play Uhura Play (current convention; final route open) +/ Uhura Editor, or redirect to Studio without a client +/play Uhura Play +/assets/* explicit Uhura browser assets +/api/editor/* Editor model and events +/api/play/* Play artifacts, events, assets, and Wasm /~studio Spock Studio -/~project/* status, diagnostics, and generation events +/~project/environment typed same-origin provider environment +/~project/status authoritative status snapshot +/~project/events project status invalidations +/~health combined readiness /~contract Spock contract /graphql/v1 /rest/v1/* @@ -378,14 +379,16 @@ One origin gives the product: - a possible atomic project-generation switch rather than two independently advancing servers. -The final route map remains open. In particular: `/` may be Play, Editor, or a -project landing page; `dev` and `start` may choose different primary surfaces; -and a backend-only project needs a useful root. +The standalone Uhura command still owns a `tiny_http` listener. The framework +instead consumes listener-free `uhura-host` routing through `spock-host`'s Axum +fallback, so the public framework process now binds one Axum listener. The +historical two-process runner remains only a transition and comparison oracle. -Current Spock uses Axum while Uhura's native host owns a `tiny_http` listener. -A real single listener therefore requires a service/router boundary from -Uhura. Two hidden listeners behind an umbrella reverse proxy are an acceptable -transition experiment, not the desired ownership model. +The combined host emits no cross-origin CORS grant by default. Its own Studio, +Editor, Play, and provider traffic is same-origin; the contributor Vite loop +uses a proxy. The standalone language server retains its explicitly permissive +local-development CORS behavior. Any future framework allowlist is an explicit +host configuration decision, not an ambient wildcard. ## 8. Both web products remain @@ -402,17 +405,21 @@ They should be mounted separately, not collapsed into a single frontend or renamed as though they did the same work. Each subsystem retains ownership of its web source and browser-facing protocol. -The distributed build must eventually build and package both web products and -Uhura Wasm before compiling or assembling the `spock` package. RFD 0020's -current Studio non-empty guard should expand to verify every required asset -family and exercise their routes from the published npm package. Node and -Vite remain build-time dependencies; neither running server should need a -Node process. - -The release design must explicitly answer what happens when a source checkout -lacks an initialized Uhura submodule. It may preserve language-only builds, -introduce a framework feature, or require the submodule for the public binary. -Silently shipping an empty Editor/Play is not an option. +The distributed build now builds the shared Uhura sidecar first, derives the +exact raw manifest SHA-256, and compiles that identity into every native binary; +Spock Studio remains a per-target embedded build. RFD 0020's release workflow +verifies each asset family, the executable-to-sidecar binding and exact +inventory, and installed Editor/Play/Wasm routes on all supported operating +systems. Node and Vite remain build-time dependencies; neither running server +needs a Node process. + +The release and source-build policy is also settled: building the framework +assets from source requires the initialized Uhura submodule. The npm package +carries the already-built sidecar whose manifest identity is compiled into its +binaries. An ordinary source binary has no packaged identity and therefore +fails closed if an executable-relative sidecar appears; source and test runs +must opt into their local trust boundary with both explicit asset-root +overrides. A missing submodule cannot silently produce an empty Editor or Play. ## 9. Configuration and linker ownership @@ -424,9 +431,10 @@ backend/app.spock ──> checked Spock contract ──┐ client/uhura.toml ──> checked Uhura program ───┘ ``` -`spock.toml` identifies these inputs and host policy. It must not redefine -either language. Uhura's required ports and Spock's exported surface should -eventually meet at a versioned linker/provider boundary. +`spock.toml` identifies the composition inputs and topology. Runtime host policy +remains in CLI options and defaults; the manifest must not redefine either +language. Uhura's required ports and Spock's exported surface should eventually +meet at a versioned linker/provider boundary. The current application-owned TypeScript provider is a valid explicit adapter and remains the integration proof. Some of that code is opaque to today's @@ -459,24 +467,26 @@ synchronized. ### Watched `dev` 1. Capture one coherent saved project snapshot. -2. Prepare the backend and any configured client candidates from that same - revision. -3. Reject stale/out-of-order work. -4. Activate the backend and, when configured, integrated Uhura Play only as a - coherent pair. -5. Retain the last-good generation when any configured subsystem is rejected. -6. Publish current diagnostics and active-generation freshness separately. +2. Compare the observed backend inputs and manifest topology with the active + fingerprints; report any difference as `restart_required` without building + or activating a backend candidate. +3. Prepare any configured client candidate against the one active backend. +4. Reject stale/out-of-order client work. +5. Publish a valid client candidate or retain the last-good Play generation + when the candidate is rejected. +6. Publish current diagnostics, changed backend inputs, and active-versus- + observed freshness separately. When a client is configured, Uhura Editor may still publish the latest static -render and diagnostics while integrated Play remains paired to an older -backend. If the first project revision is invalid, `spock dev` may bind a -diagnostics/control shell without an active project generation; fixed `spock -start` may instead fail before binding. The exact cold-invalid route behavior -remains open. +render and diagnostics while Play remains bound to the active backend. Client +publication continues while a backend restart is required. Both modes fail +before binding when the initial backend is invalid. `start` also fails for an +invalid configured client. `dev` may bind with current Editor diagnostics and +no Play generation, then activate the first valid client save. -The lifecycle stops there in this document. Whether a backend candidate -retains a database, creates a new world, rebases state, or requires a reset is -the subject of [RFD 0023](0023-development-state-reload.md). +No watched backend candidate exists in this implementation. Whether a future +candidate retains a database, creates a new world, rebases state, or requires a +reset is the subject of [RFD 0023](0023-development-state-reload.md). ## 11. Alternatives considered @@ -520,9 +530,9 @@ Rejected. It duplicates semantic authority, harms standalone checking, and turns the composition manifest into a grab bag. Point to the client root; leave the client contract there. -## 12. Working conclusions and open decisions +## 12. Accepted first implementation -The recommendations this study asks the project to discuss are: +The accepted framework shape is: 1. One installed/public command: `spock`. 2. `spock-cli` remains its crate and binary owner. @@ -533,60 +543,254 @@ The recommendations this study asks the project to discuss are: 7. Add `spock-project` and `spock-host` responsibility crates. 8. Extract a reusable `uhura-host` inside the submodule. 9. Preserve Studio, Editor, Play, and GraphiQL as separate surfaces. -10. Prefer one listener and one origin. - -The following remain open: - -1. Exact `spock.toml` keys, versioning, and diagnostics. -2. Parent-directory project discovery behavior. -3. The default `new` template, whether it has `--backend-only`, and whether a - client-focused form is useful (it would still contain the required empty - backend). -4. Exact empty-authority HTTP and GraphQL behavior. -5. Final route allocation and root-page behavior. -6. The shared HTTP service abstraction between Axum and Uhura. -7. Public names for Uhura-specific `fmt`, `trace`, and Editor-only commands. -8. Whether multiple client/backend roots ever earn syntax. -9. Missing-submodule and language-only source-build behavior. -10. Release embedding versus a packaged internal helper/runtime. -11. The provider overlay and future linker format. -12. What operational guarantees the name `start` implies, including whether - it always materializes fresh seed, may open a named state/world, accepts a - database path, and exposes development-only browser tools. -13. Every backend state/reload decision delegated to RFD 0023. - -## 13. Sequence before implementation - -1. Land this framework record and - [RFD 0023](0023-development-state-reload.md) as studies. -2. Decide the development-state model in - [RFD 0023](0023-development-state-reload.md). -3. Add `spock-project` with manifest, discovery, scaffold, and adoption tests. -4. Refactor current file commands behind reusable library entrypoints. -5. Extract `uhura-host` without changing direct Uhura behavior. -6. Implement project-wide `check`, currently provable provider diagnostics, - and an explicit unchecked-adapter report. -7. Compose one listener and both asset families. -8. Implement fixed-generation `start`. -9. Implement `dev` only against the chosen RFD 0023 semantics. -10. Expand RFD 0020's npm build and verification pipeline. - -The ordering is deliberate: combining two live runtimes before deciding what -a backend save means would make the hardest product behavior an accidental -property of whichever host refactor lands first. - -## 14. Acceptance scenarios for a later implementation - -- `spock new demo` produces the documented project shape. -- `spock init` never overwrites or silently relocates existing sources. -- A client project with an empty `backend/app.spock` checks and starts. -- A backend-only project starts and exposes its backend tools. -- One public port serves the configured Spock APIs and browser tools. -- A broken half never publishes a mixed integrated-Play/backend generation; - Editor freshness remains explicit. -- The npm package contains both web products and Uhura Wasm. -- `spock run app.spock` remains valid. -- Direct Uhura checks and tests remain independently runnable. +10. One listener and one origin. + +### 12.1 Client-live, backend-pinned `dev` + +The first `spock dev` activates exactly one valid backend generation. Client +changes may build and publish immutable last-known-good Uhura generations +against that active backend. A `.spock` change, referenced seed-asset change, +or backend/topology change in `spock.toml` is observed and reported as +`restart_required`, but never constructs, opens, reseeds, or swaps a backend +inside that process. Returning all backend inputs and topology bytes to their +active fingerprints clears the warning without touching the database. + +Client publication continues while a restart is required and explicitly +names the active backend generation. A rejected client attempt retains the +previous Play generation. Applying a backend edit requires an explicit process +restart; while v0 load remains destructive, the CLI must say that restart +reconstructs state from seed. The one future activation marker belongs at this +disposition seam: + +```text +TODO(RFD-0023): replace restart-required with off-path backend candidate +construction and an explicit activation policy after development-world +semantics are accepted. Never reopen or mutate the active world here. +``` + +`spock start` has no watcher and serves one fixed generation. Both modes +require a valid backend before binding. A configured invalid client also makes +`start` fail before binding; `dev` may bind backend tools plus current Editor +diagnostics with Play unavailable, and the first valid client save activates +Play. + +Before named-state ownership or database opening, preparation re-resolves the +project topology and recaptures backend and client fingerprints. Any content +change, capture instability, or safe in-project root-symlink retarget makes the +whole attempt unstable; `start` never binds a snapshot assembled across saves. + +Once a client attempt becomes observable as `building`, its lifecycle always +finishes it as published or rejected and then invalidates project status. A +newer project observation rejects a now-ineligible build immediately, including +when the new topology removes the client. The coordinator transition is proven +off to the side before Uhura installation, so a publication error leaves the +last-good client untouched and cannot strand the status machine waiting for +another filesystem event. + +### 12.2 Routes and empty authority + +The stable route ownership is: + +```text +/ Uhura Editor when configured; otherwise redirect to /~studio +/play Uhura Play when a client is configured; otherwise 404 +/assets/* Uhura browser assets with a client; otherwise 404 +/api/editor/* Editor state/events with a client; otherwise 404 +/api/play/* Play artifacts/events/Wasm with a client; otherwise 404 +/~studio Spock Studio +/~contract active Spock contract +/~personas, /~whoami Spock development identity +/graphql/v1 GraphQL when the authority derives fields +/rest/v1/* REST and RPC +/storage/v1/* storage +/~project/environment integrated provider host environment +/~project/status authoritative project status snapshot +/~project/events project status invalidation events +/~health aggregate host readiness +``` + +The framework owns final fallback, cross-origin policy, body limits, and +collision checks; the accepted same-origin default installs no CORS grant. +Unknown protocol paths return protocol 404/method responses, never SPA HTML. +An empty or comment-only backend is valid. It serves contract metadata and the +configured client, but `/graphql/v1` is absent with a structured 404 because +there is no GraphQL capability to advertise. + +### 12.3 State and process ownership + +The first framework release defaults to an in-memory database. `--db PATH` +selects an explicitly disposable named database that is still reconstructed +from seed on each process start. Before touching a named database, its WAL, +SHM, or mutable framework state, the host holds an exclusive OS advisory lock +for the process lifetime. The lock is released by closing its handle, including +after abnormal process termination; correctness never depends on deleting a +sentinel file or guessing whether a PID is stale. + +`.spock/dev/` is reserved for ignored framework development state. In-memory +hosts do not serialize unrelated processes through a project-wide lock because +they share no mutable database. + +The advisory object lives under the reserved sibling +`.spock-named-state-locks/` namespace, never at a valid database path. The host +normalizes and locks the database directory entry, then passes that exact path +to destructive bootstrap; a symlink or alternate parent spelling therefore +cannot make locking and opening select different worlds. + +### 12.4 Build, assets, and provider environment + +The root workspace consumes `uhura-host` by direct path from the initialized +Uhura submodule. A full source build therefore requires recursive submodules; +Uhura remains a separate workspace with its own lockfile. The tested framework +toolchain is exactly Rust 1.92.0 with Cargo resolver 3. This is an exact build +pin, not a lower MSRV promise. + +The npm package carries one shared, platform-independent Uhura web/Wasm sidecar +tree plus a versioned manifest of protocol versions, commits, hashes, and +sizes. The release asset job hashes the exact manifest bytes before the native +matrix runs; every distribution executable captures that SHA-256 and rejects a +different executable-relative manifest before parsing it. The manifest then +provides per-file integrity and compatibility checks over the immutable served +snapshot. This authenticates the sidecar only relative to a trusted executable: +it does not sign the package, protect replacement of both artifacts, or cover a +compromised release workflow. The explicit paired test/source override is +deliberately unanchored local input; `uhura-host` never searches a source tree at +runtime. Spock Studio stays embedded initially. Canonical scaffold bytes are +embedded in `spock-project`, so `spock new` does not need a checkout. + +Integrated Play receives framework-owned facts from +`/~project/environment` using protocol `spock-host-environment/1`: + +```json +{ + "protocol": "spock-host-environment/1", + "mode": "dev", + "project_generation_id": 1, + "backend_generation_id": 1, + "authority": { + "graphql_path": "/graphql/v1", + "rpc_path": "/rest/v1/rpc", + "storage_path": "/storage/v1" + } +} +``` + +`authority.graphql_path` is a capability, not merely a conventional URL. It is +`null` for an empty/comment-only backend because that active generation has no +GraphQL operation root. A provider that recognizes this environment must keep +using its integrated RPC and storage paths and report GraphQL as unavailable; +it must not reinterpret the deliberate `null` as a reason to contact the +standalone fallback configured in `uhura.toml`. + +Uhura providers may prefer this same-origin environment and fall back to their +committed absolute configuration in standalone mode. The framework does not +merge or rewrite arbitrary provider JSON. + +### 12.5 Status, events, and readiness + +`/~project/status` uses protocol `spock-project-status/1`. Its snapshot names +the mode and observed revision/fingerprint; active project, backend, and client +generation IDs; active and observed backend fingerprints; backend freshness +(`active` or `restart_required`); client state (`absent`, `building`, `active`, +`cold_invalid`, or `rejected_last_good`); the latest client attempt separately +from the generation serving bytes; Editor freshness; changed input paths; +diagnostics; and aggregate readiness/degradation. An active client's +`source_fingerprint` identifies the exact captured Uhura source snapshot, not a +digest of toolchain-derived browser artifacts. IDs are monotonic within one +host session and are not presented as durable identities across restart. + +`/~project/events` uses SSE event protocol `spock-project-event/1`. Events are +monotonic invalidations containing the session event ID and authoritative +status URL; publication updates artifacts and status before broadcasting. The +host does not promise unbounded event replay. On initial connection, reconnect, +an unknown `Last-Event-ID`, or a detected gap, the client fetches the current +status snapshot. Project, Editor, and Play event hubs belong to the host +session and survive client-generation swaps. One four-stream admission budget +covers all three event surfaces. Saturation returns 503 with `Retry-After: 1`; +disconnect, shutdown, or stream completion returns its permit. + +`/~health` returns 200 once the listener and backend generation are active. +Restart-required, cold-invalid client state, and retained-last-good client +failures are reported as degraded but ready; they do not make working APIs +unready. Before backend activation it returns 503. Fixed `start` never binds +with an invalid configured component. + +### 12.6 Scaffolding and budgets + +`spock new NAME` defaults to a minimal full-stack project: the exact v1 +manifest, an empty `backend/app.spock`, and a self-contained Uhura starter +with no required remote provider. `--backend-only` omits `[client]` and the +client tree. `spock init` adopts existing roots without moving or overwriting +them; ambiguity is an error with choices. + +Initial release budgets on the recorded reference machine are: source +`start`/`dev` readiness within 5 seconds and no more than twice the pre-framework +backend startup baseline; valid client publication p95 within 1.5 seconds; +idle observer CPU at or below 2%; a 250-revision soak with RSS growth at most +25 MiB and file-descriptor/thread growth at most three; shutdown and port +rebind within 2 seconds; and a packed all-platform npm artifact at most 25 MiB. + +Still deferred are backend world reuse/rebase/migration, Play state-preserving +HMR, automatic provider TypeScript build supervision, multiple backends or +clients, a published `uhura-host` crate, and the native-event versus polling +observer optimization. + +## 13. Implementation record + +The sequence was completed in the intended dependency order: + +1. `uhura-host`, `spock-project`, and the immutable backend-generation seam + landed independently. +2. The pinned Uhura submodule and direct Cargo dependency were integrated + atomically while Uhura retained its separate workspace and lockfile. +3. `spock-host` composed one listener and fixed-generation `start`. +4. `dev` added client-live/backend-pinned observation with the single in-code + RFD 0023 TODO at the backend disposition seam. +5. The public CLI added project-wide `check`, `new`, and `init` while retaining + explicit `.spock` file workflows. +6. RFD 0020's npm workflow added the shared Uhura web/Wasm sidecar, four native + launchers, exact package guards, and installed cross-platform route smokes. + +The ordering remains part of the design: the combined host owns immutable +artifacts and lifecycle, but it has no API that replaces the active backend. +That makes the current doctrine mechanically visible instead of relying on a +watcher convention. + +## 14. Acceptance evidence + +- `spock new demo` produces the documented, project-checkable shape; + `--backend-only` omits the client. +- `spock init` uses create-new writes, publishes the manifest last, and never + overwrites or silently relocates existing sources. On Unix, retained parent + handles let rollback remove exact invocation-owned files; created directories + are preserved and reported because `mkdir` cannot atomically return an + ownership handle. On Windows, retained root, directory, and file leases block + replacement through commit; failure handling is deliberately non-mutating and + reports every known creation as a residual rather than risking deletion or + overwrite through a replace-capable rename. +- Planned paths reject Windows device aliases, alternate-data-stream syntax, + trailing-dot/space aliases, and case- or Unicode-normalization-equivalent + duplicate destinations before filesystem mutation. +- Empty-authority full-stack and backend-only projects check and start. +- For a full-stack project, one public port serves the configured Spock APIs, + Studio, Uhura Editor, Play, status, environment, health, browser assets, and + Wasm. Backend-only client routes return structured 404 responses. +- Valid, invalid, and recovered client saves prove last-known-good publication; + a client generation can publish while backend status is + `restart_required` and remains bound to the active backend generation. +- Editing and then exactly reverting backend inputs changes only observation + status; backend generation and world identity remain unchanged. +- Real-browser acceptance covers Editor, Play navigation, hard-reloaded Play, + and Studio with no console warnings or errors. +- Root and independently runnable Uhura Rust gates, plus the Uhura + browser/provider gate, pass in their respective workspaces. The first `0.5.0` + npm release dry run passed its four-target build and macOS/Linux/Windows + installed-package verification on 2026-07-15 + ([Actions run 29379605382](https://github.com/gridaco/spock/actions/runs/29379605382)); + that workflow remains the authoritative full-matrix integration proof. +- The local package-topology smoke contains exactly 21 files and the release + workflow rejects a packed artifact above 25 MiB. Release CI remains + authoritative for the four real platform binaries. +- `spock run app.spock` remains valid and shares the named-state safety lock. ## 15. Related documents @@ -600,9 +804,9 @@ property of whichever host refactor lands first. asset build. - [RFD 0023](0023-development-state-reload.md) — saved-source reload and authoritative development state. -- [Uhura RFC 0001](https://github.com/gridaco/uhura/blob/42ece8e3c44efe89d3c9417761504e7b190db230/docs/rfcs/0001-project-foundation.md) +- [Uhura RFC 0001](https://github.com/gridaco/uhura/blob/8f20987d1f19b927b3d067872885c9adaed83b6e/docs/rfcs/0001-project-foundation.md) — the language/runtime ownership boundary. -- [Uhura RFC 0002](https://github.com/gridaco/uhura/blob/42ece8e3c44efe89d3c9417761504e7b190db230/docs/rfcs/0002-model-driven-editor-live-updates.md) +- [Uhura RFC 0002](https://github.com/gridaco/uhura/blob/8f20987d1f19b927b3d067872885c9adaed83b6e/docs/rfcs/0002-model-driven-editor-live-updates.md) — coherent saved-source capture and last-known-good publication. -- [Uhura specification](https://github.com/gridaco/uhura/blob/42ece8e3c44efe89d3c9417761504e7b190db230/docs/spec/README.md) +- [Uhura specification](https://github.com/gridaco/uhura/blob/8f20987d1f19b927b3d067872885c9adaed83b6e/docs/spec/README.md) — current Uhura contract authority. diff --git a/docs/rfd/0023-development-state-reload.md b/docs/rfd/0023-development-state-reload.md index 37371ea..9508d13 100644 --- a/docs/rfd/0023-development-state-reload.md +++ b/docs/rfd/0023-development-state-reload.md @@ -1,15 +1,51 @@ # RFD 0023 — Development reload, state continuity, and the auto-migration boundary -Status: **problem study; direction under evaluation.** This document records -the constraints, vocabulary, candidate models, and decisions required before -Spock and Uhura share a development host. It does not amend the v0 contract, -promise state-preserving reload, or introduce production migrations. +Status: **problem study; long-term direction under evaluation.** This document +does not amend the v0 contract, promise state-preserving reload, or introduce +production migrations. A deliberately smaller interim host policy was accepted +and implemented on 2026-07-15 so framework composition can proceed without +selecting a world, rebase, or migration model. The working candidate is **non-destructive development worlds with an optional three-way state rebase**. Worlds are the safety and rollback primitive. Rebase is only a convenience for initializing a new world when compatibility can be proved. Both parts remain subject to experiments and review. +## Interim implementation policy — client live, backend pinned + +During the first combined `spock dev`, one valid Spock backend generation is +constructed and activated exactly once. Uhura client changes continue to use +coherent capture, current diagnostics, immutable candidates, newest-result +ordering, and last-known-good publication against that active generation. + +Backend source, referenced seed assets, and backend/topology manifest changes +are observed and fingerprinted but receive the terminal disposition +`restart_required`. They never call `engine::open`, delete or reopen a +database, replay seed, construct a shadow backend, or swap any backend-owned +contract, connection, signer, blob store, route, or background task. Reverting +all changed backend inputs and topology to the active fingerprints clears the +warning without touching state. Client publication may continue during the +warning and must name the active backend generation, not the changed source. + +An explicit process restart is the only way this first implementation adopts +a backend edit, and the CLI warns that current v0 startup reconstructs the +database from seed. This policy is not a candidate answer to auto-migration; +it is the safe no-activation baseline against which every later proposal must +improve. The implementation has exactly one future marker at the disposition +seam and no migration logic elsewhere: + +```text +TODO(RFD-0023): replace restart-required with off-path backend candidate +construction and an explicit activation policy after development-world +semantics are accepted. Never reopen or mutate the active world here. +``` + +The implementation establishes an immutable backend generation and explicit +project-generation status, but deliberately constructs no watched backend +candidate. This is important: lifecycle ownership and coherent observation are +prerequisites for a future solution, not acceptance of the world/rebase model +studied below. + ## 0. Why this must be decided before runtime composition RFD 0006 promises a fast source-to-play loop by reloading checked IR into a @@ -41,7 +77,7 @@ meaning independently. Uhura does not currently migrate a running Play session across source edits. Its accepted RFC deliberately calls the feature **saved-source live rebuilding**, and lists Play runtime-state migration/HMR as a non-goal -([Uhura RFC 0002](https://github.com/gridaco/uhura/blob/42ece8e3c44efe89d3c9417761504e7b190db230/docs/rfcs/0002-model-driven-editor-live-updates.md)). +([Uhura RFC 0002](https://github.com/gridaco/uhura/blob/8f20987d1f19b927b3d067872885c9adaed83b6e/docs/rfcs/0002-model-driven-editor-live-updates.md)). What Uhura already establishes — and what a combined host should share first — is: @@ -138,13 +174,22 @@ Consequently, replaying GraphQL/RPC requests is not a faithful state model. Re-execution under a new function body, actor, clock, or generated identifier can yield a different database even when every request succeeds. -### 2.4 The current runtime has no generation seam +### 2.4 The runtime has a generation seam, not an activation policy + +The initial framework implementation added +[`BackendGeneration`](../../crates/spock-runtime/src/generation.rs), which +binds an immutable `App`, checked contract, captured-input and contract +fingerprints, authority router, signer/blob ownership, and a one-shot +background-task lifecycle. The project host owns the active generation and its +status coordinator; there is intentionally no method that replaces the active +backend inside a process. -[`App`](../../crates/spock-runtime/src/lib.rs) owns an immutable `Contract`, a -single serialized `Connection`, a randomly generated URL signer, and a blob -store. The GraphQL schema and Axum router eagerly capture that `App`. A reload -design therefore needs a new immutable generation boundary and a supervisor; -mutating fields inside the current `App` is not a coherent cutover protocol. +That closes the lifecycle precondition identified by the original study, but +not the state problem. The watcher fingerprints backend inputs and reports +`restart_required`; it does not construct a second `BackendGeneration`, open a +shadow database, classify compatibility, or activate a candidate. A future +reload design must add those operations explicitly rather than mutate fields +inside the active `App`. ### 2.5 Storage is part of authoritative state @@ -152,9 +197,11 @@ The default blob implementation stores bytes in a hidden SQLite table keyed to `storage_object` metadata ([`storage/blob.rs`](../../crates/spock-runtime/src/storage/blob.rs)). A metadata-only transfer can create committed objects whose bytes are absent. -The signer is random per `App`, so recreating an `App` on a behavior-only edit -currently invalidates outstanding signed URLs. A sweep task is spawned for a -serving storage contract and has no generation-level cancellation handle. +The signer is random per `App`. The interim pinned process therefore keeps it +stable, but any future generation replacement must decide whether outstanding +signed URLs belong to a session, world, or generation. Storage sweep work now +has a generation-owned cancellation handle; transfer, fencing, and lease rules +across two worlds remain unresolved. Rows, blob bytes, pending uploads, signing lifetime, and sweep ownership all belong in the reload study. @@ -963,7 +1010,13 @@ must not leave disk growth unbounded. ### P1 — coherent last-good generations -- Add stable project snapshot capture and newest-revision ordering. +The interim host already supplies coherent observation, an immutable active +backend generation, and last-good client publication. P1 here begins where +that safe baseline stops: complete off-path **backend** candidates and an +explicit fresh-world activation decision. + +- Preserve stable project snapshot capture and newest-revision ordering while + backend candidate work moves off-path. - Build complete backend candidates off-path. - Keep the current generation serving on source failure. - Use fresh shadow worlds only; make no preservation claim. @@ -1010,10 +1063,12 @@ must not leave disk growth unbounded. - Add explicit rename maps or type conversions only when real projects demand them. -[RFD 0022](0022-spock-framework.md)'s combined `spock dev` should not be -treated as complete before at -least P1 and P2. Whether P5 is required for the first public release is an -open product decision; it should not delay the correctness model itself. +[RFD 0022](0022-spock-framework.md)'s interim combined `spock dev` is complete +under the narrower client-live/backend-pinned contract at the top of this +document. No feature should be described as backend live reload or +state-preserving activation before at least P1 and P2. Whether P5 is required +for a later backend-reload release is an open product decision; it should not +delay the correctness model itself. ## 16. Required experiments and conformance matrix @@ -1189,7 +1244,7 @@ The central idea is not “migrate on every save.” It is: combined host study. - [v0 specification](../spec/v0.md) — current fresh materialization and seed semantics. -- [Uhura RFC 0002](https://github.com/gridaco/uhura/blob/42ece8e3c44efe89d3c9417761504e7b190db230/docs/rfcs/0002-model-driven-editor-live-updates.md) +- [Uhura RFC 0002](https://github.com/gridaco/uhura/blob/8f20987d1f19b927b3d067872885c9adaed83b6e/docs/rfcs/0002-model-driven-editor-live-updates.md) — saved-source capture, stale publication, and Play migration non-goal. ### External primary sources diff --git a/docs/rfd/README.md b/docs/rfd/README.md index c9327c1..441fe2f 100644 --- a/docs/rfd/README.md +++ b/docs/rfd/README.md @@ -2,9 +2,11 @@ RFD means request for discussion. -This directory is for design sketches, language experiments, and future-facing -proposals that are useful to keep, but are not the current implementation -target. +This directory records design sketches, language experiments, implementation +decisions, and focused problem studies. Each RFD declares its own status: +accepted documents and accepted sections may govern current implementation, +while proposed or study sections remain non-normative until explicitly +accepted. -Use this space for ideas that should be discussed before they become part of -the language. +Use this space for ideas that need discussion and for durable decisions whose +reasoning should remain visible after implementation. diff --git a/npm/LICENSE b/npm/LICENSE new file mode 100644 index 0000000..9fea68a --- /dev/null +++ b/npm/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Grida + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/npm/README.md b/npm/README.md index 5ee2f5e..61d9b31 100644 --- a/npm/README.md +++ b/npm/README.md @@ -2,38 +2,77 @@ > It's only logical. -Spock is an early programming language for prototyping application backends as -a small, inspectable source of truth. You describe your tables, functions, and -rules once; Spock materializes a running backend (embedded SQLite) and serves -it over GraphQL and REST, with generated TypeScript types. +Spock is an early meta-framework for prototyping an authoritative application +backend and an optional Uhura client as one inspectable project. The Spock +language materializes the backend from tables, functions, and rules; Uhura +defines the client experience; one command checks and serves both on one +origin. + +`0.5.0` is the first framework-capable npm release. Releases through `0.4.0` +expose only the standalone `.spock` language commands. ## Install ```sh # run without installing -npx spock run app.spock +npx spock new demo +cd demo +npx spock dev # or install globally npm i -g spock -spock run app.spock ``` This package bundles a prebuilt native binary for your platform -(macOS arm64/x64, Linux x64, Windows x64). There is no build step and no -network access at install time. +(macOS arm64/x64, GNU-libc Linux x64, Windows x64), plus one shared Uhura +Editor/Play web and WebAssembly sidecar. Alpine and other musl-based Linux +distributions are not supported yet. There is no build step and no network +access at install time. ## Usage ```sh -spock check app.spock # parse + check a program -spock run app.spock # materialize + serve (GraphQL, REST, /~studio) -spock gen types app.spock # emit TypeScript types -spock gen graphql-schema app.spock +spock new demo # create a full-stack project +cd demo +spock check # check manifest, backend, and client +spock dev # client-live, backend-pinned development host +spock start # fixed combined generation +spock init [PATH] # adopt existing sources without moving them +spock new api --backend-only # omit the optional Uhura client + +# language-level escape hatches remain +spock check backend/app.spock # parse + check one program +spock run backend/app.spock # materialize + serve (GraphQL, REST, /~studio) +spock gen types backend/app.spock # emit TypeScript types +spock gen graphql-schema backend/app.spock ``` -`spock run` serves the GraphQL API at `/graphql/v1`, REST at `/rest/v1`, the -contract at `/~contract`, and the studio console at `/~studio` — all from the -single binary, offline. +`spock run` serves REST at `/rest/v1`, the contract at `/~contract`, and the +studio console at `/~studio` — all from the single binary, offline. It also +serves GraphQL at `/graphql/v1` when the contract derives an operation root; an +empty authority reports that capability as unavailable instead of inventing a +placeholder field. + +For a project with a configured client, `spock start` and `spock dev` serve the +Uhura Editor at `/` and integrated Play at `/play`. Both modes serve Spock +Studio and the framework protocols on the same origin, with no cross-origin +CORS grant by default. A backend-only project redirects `/` to Studio and +returns structured 404 responses for client routes. +The framework package's executable-bound shared sidecar provides the browser +and WebAssembly assets offline. + +In `spock dev`, valid client saves publish live. Invalid saves keep the last +good client generation when one exists; an initially invalid client is reported +as `cold_invalid` while Editor diagnostics remain available. Backend +inputs—including the `.spock` source and referenced seed assets—and +topology-affecting `spock.toml` saves are noticed and reported as +restart-required, but never migrate, reseed, or replace the running database. +Restarting the command reconstructs backend state from seed. + +The one-command host does not merge language ownership. Spock remains the +authority for durable facts, policy, and mutations; Uhura owns the client +experience and non-authoritative UI-session state. `spock.toml` composes their +roots, while each language keeps its own checker and configuration. ## Links diff --git a/npm/THIRD_PARTY_NOTICES.md b/npm/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..d415625 --- /dev/null +++ b/npm/THIRD_PARTY_NOTICES.md @@ -0,0 +1,27 @@ +# Third-party notices + +The npm package includes Uhura Editor, Play, host, and WebAssembly artifacts +from the [Uhura project](https://github.com/gridaco/uhura), used under the MIT +License: + +> MIT License +> +> Copyright (c) 2026 Grida +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to +> deal in the Software without restriction, including without limitation the +> rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +> sell copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +> FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +> IN THE SOFTWARE. diff --git a/npm/bin/spock.js b/npm/bin/spock.js old mode 100644 new mode 100755 index a24264e..c8acf31 --- a/npm/bin/spock.js +++ b/npm/bin/spock.js @@ -6,7 +6,8 @@ // trusting which package installed) is the robust path across npm/pnpm/yarn/bun. "use strict"; -const { execFileSync } = require("node:child_process"); +const { spawn } = require("node:child_process"); +const { constants } = require("node:os"); const path = require("node:path"); const fs = require("node:fs"); @@ -14,6 +15,27 @@ const key = `${process.platform}-${process.arch}`; const exe = process.platform === "win32" ? "spock.exe" : "spock"; const bin = path.join(__dirname, "..", "binaries", key, exe); +if (key === "linux-x64") { + let nonGlibcRuntime = false; + try { + const report = process.report?.getReport?.(); + nonGlibcRuntime = + report !== undefined && + report !== null && + typeof report.header?.glibcVersionRuntime !== "string"; + } catch { + // If Node cannot report libc, let the native loader provide the diagnosis. + } + if (nonGlibcRuntime) { + process.stderr.write( + "spock: the bundled linux-x64 binary requires GNU libc.\n" + + "Alpine and other musl-based distributions are not supported yet; " + + "use a GNU-libc image or build Spock from source.\n", + ); + process.exit(1); + } +} + if (!fs.existsSync(bin)) { process.stderr.write( `spock: no prebuilt binary for ${key}.\n` + @@ -23,11 +45,107 @@ if (!fs.existsSync(bin)) { process.exit(1); } -try { - execFileSync(bin, process.argv.slice(2), { stdio: "inherit" }); -} catch (err) { - // Propagate the child's exit code; a non-numeric status means a spawn error. - if (typeof err.status === "number") process.exit(err.status); - process.stderr.write(`spock: failed to run binary: ${err.message}\n`); - process.exit(1); +const child = spawn(bin, process.argv.slice(2), { stdio: "inherit" }); +let spawnError; +let forwardingError; +let spawned = false; +let pendingSignal; +let windowsConsoleFallback; + +// `start`, `dev`, and `run` are long-lived. A signal sent specifically to the +// npm shim (as opposed to the whole terminal process group) must still reach +// the Rust owner so it can release its listener, locks, and background tasks. +const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]; +const handlers = new Map(); +for (const signal of forwardedSignals) { + const handler = () => { + if (child.exitCode !== null || child.signalCode !== null) return; + try { + pendingSignal = signal; + if (process.platform === "win32" && signal === "SIGINT") { + // A terminal Ctrl+C is broadcast to every process sharing the Windows + // console, including the already-spawned Rust child. Calling + // child.kill("SIGINT") here would instead terminate it abruptly. Keep + // the shim alive while the child drains, with a bounded fallback for a + // programmatic signal that was delivered only to this Node process. + if (windowsConsoleFallback !== undefined) { + child.kill("SIGKILL"); + } else { + windowsConsoleFallback = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, 5_000); + } + return; + } + child.kill(signal); + } catch (error) { + // The child may have exited between the state check and kill(2). + if (error?.code !== "ESRCH" && forwardingError === undefined) { + forwardingError = { signal, error }; + } + } + }; + try { + process.on(signal, handler); + handlers.set(signal, handler); + } catch { + // Some signals are unavailable on some Node/Windows combinations. + } } + +child.once("spawn", () => { + spawned = true; +}); + +child.on("error", (error) => { + if (!spawned) { + spawnError ??= error; + } else if (error?.code !== "ESRCH" && forwardingError === undefined) { + forwardingError = { signal: pendingSignal ?? "signal", error }; + } +}); + +child.once("close", (code, signal) => { + if (windowsConsoleFallback !== undefined) { + clearTimeout(windowsConsoleFallback); + } + for (const [name, handler] of handlers) process.removeListener(name, handler); + if (spawnError) { + process.stderr.write(`spock: failed to run binary: ${spawnError.message}\n`); + process.exitCode = 1; + return; + } + if (forwardingError) { + const details = + forwardingError.error instanceof Error + ? forwardingError.error.message + : String(forwardingError.error); + process.stderr.write( + `spock: failed to forward ${forwardingError.signal}: ${details}\n`, + ); + } + if (signal) { + const number = signal ? constants.signals[signal] : undefined; + process.exitCode = typeof number === "number" ? 128 + number : 1; + // On Unix, preserve true signal termination for shells and process + // supervisors. Windows and unsupported Node signal combinations retain + // the conventional 128+signal fallback above. + if (process.platform !== "win32") { + setImmediate(() => { + try { + process.kill(process.pid, signal); + } catch { + // The fallback exit code is already installed. + } + }); + } + } else if (forwardingError) { + process.exitCode = 1; + } else if (typeof code === "number") { + process.exitCode = code; + } else { + process.exitCode = 1; + } +}); diff --git a/npm/package.json b/npm/package.json index c3e810c..11d58b3 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,13 +1,15 @@ { "name": "spock", - "version": "0.4.0", - "description": "Spock — prototype application backends as a single, inspectable source of truth. It's only logical.", + "version": "0.5.0", + "description": "Spock — prototype an authority backend and optional Uhura client as one inspectable project.", "bin": { "spock": "bin/spock.js" }, "files": [ "bin/", - "binaries/" + "binaries/", + "share/", + "THIRD_PARTY_NOTICES.md" ], "engines": { "node": ">=18" @@ -15,6 +17,8 @@ "keywords": [ "spock", "backend", + "full-stack", + "uhura", "prototype", "language", "graphql", diff --git a/npm/scripts/sidecar.mjs b/npm/scripts/sidecar.mjs new file mode 100644 index 0000000..01f3d8f --- /dev/null +++ b/npm/scripts/sidecar.mjs @@ -0,0 +1,440 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { + lstat, + mkdir, + open, + readFile, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SIDECAR_PROTOCOL = "spock-asset-sidecar/1"; +const PROTOCOLS = Object.freeze({ + environment: "spock-host-environment/1", + project_status: "spock-project-status/1", + project_event: "spock-project-event/1", + editor_state: "uhura-editor-state/1", + editor_event: "uhura-editor-event/0", + ir: "uhura-ir/0", + inspect: "uhura-inspect/0", + view: "uhura-view/0", + provider: "uhura-provider/0", +}); +const REQUIRED_ROUTES = Object.freeze([ + "/api/editor/state", + "/api/editor/events", + "/api/play/events", + "/api/play/ir.json", + "/api/play/wasm/uhura_wasm.js", +]); +const REQUIRED_WASM = Object.freeze([ + "wasm/uhura_wasm.js", + "wasm/uhura_wasm_bg.wasm", +]); +const COMMIT_PATTERN = /^[0-9a-f]{40}$/; +const HASH_PATTERN = /^[0-9a-f]{64}$/; +const PORTABLE_SEGMENT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const WINDOWS_DEVICE_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; + +function fail(message) { + throw new Error(`sidecar: ${message}`); +} + +function usage() { + return [ + "usage:", + " node npm/scripts/sidecar.mjs assemble \\", + " --web-dir uhura/web/dist \\", + " --wasm-dir uhura/crates/uhura-wasm/pkg/web \\", + " --out-dir npm/share/spock/uhura \\", + " --spock-commit <40-hex-sha> --uhura-commit <40-hex-sha>", + " node npm/scripts/sidecar.mjs verify --root npm/share/spock/uhura", + " node npm/scripts/sidecar.mjs self-test", + ].join("\n"); +} + +function parseArgs(argv) { + const [command, ...rest] = argv; + if (command === "self-test") { + if (rest.length !== 0) fail(usage()); + return { command, values: new Map() }; + } + if (command !== "assemble" && command !== "verify") fail(usage()); + const values = new Map(); + for (let index = 0; index < rest.length; index += 2) { + const key = rest[index]; + const value = rest[index + 1]; + if (!key?.startsWith("--") || value === undefined || value.startsWith("--")) { + fail(usage()); + } + if (values.has(key)) fail(`duplicate argument ${key}`); + values.set(key, value); + } + const expected = + command === "assemble" + ? ["--web-dir", "--wasm-dir", "--out-dir", "--spock-commit", "--uhura-commit"] + : ["--root"]; + for (const key of expected) { + if (!values.has(key)) fail(`missing ${key}\n${usage()}`); + } + for (const key of values.keys()) { + if (!expected.includes(key)) fail(`unknown argument ${key}\n${usage()}`); + } + return { command, values }; +} + +function portablePath(...parts) { + return parts.filter(Boolean).join("/"); +} + +function comparePath(left, right) { + return Buffer.compare(Buffer.from(left.path, "utf8"), Buffer.from(right.path, "utf8")); +} + +function assertOutputDirectory(outDir) { + const spockDir = dirname(outDir); + const shareDir = dirname(spockDir); + if ( + basename(outDir) !== "uhura" || + basename(spockDir) !== "spock" || + basename(shareDir) !== "share" + ) { + fail("--out-dir must end in share/spock/uhura"); + } +} + +function assertAssetDirectory(root, rootStat) { + if (rootStat?.isSymbolicLink()) fail(`asset directory may not be a symlink: ${root}`); + if (!rootStat?.isDirectory()) fail(`asset directory is missing: ${root}`); +} + +function selfTest() { + const root = resolve("sidecar-output-suffix-self-test"); + assertOutputDirectory(join(root, "share", "spock", "uhura")); + + for (const invalid of [ + // Regression: checking only the last two segments accepted this spelling. + join(root, "spock", "uhura"), + join(root, "shared", "spock", "uhura"), + join(root, "share", "other", "uhura"), + join(root, "share", "spock", "other"), + join(root, "share", "spock", "uhura", "extra"), + ]) { + let rejected = false; + try { + assertOutputDirectory(invalid); + } catch (error) { + rejected = error?.message === "sidecar: --out-dir must end in share/spock/uhura"; + } + if (!rejected) fail(`self-test accepted invalid --out-dir: ${invalid}`); + } + + const symlinkRoot = join(root, "symlink-root"); + try { + assertAssetDirectory(symlinkRoot, { + isSymbolicLink: () => true, + isDirectory: () => false, + }); + fail("self-test accepted a symlinked asset root"); + } catch (error) { + if (error?.message !== `sidecar: asset directory may not be a symlink: ${symlinkRoot}`) { + throw error; + } + } + + const missingRoot = join(root, "missing-root"); + try { + assertAssetDirectory(missingRoot, null); + fail("self-test accepted a missing asset root"); + } catch (error) { + if (error?.message !== `sidecar: asset directory is missing: ${missingRoot}`) throw error; + } + + process.stdout.write("verified sidecar tooling self-test\n"); +} + +async function regularFiles(root, prefix = "") { + const rootStat = await lstat(root).catch(() => null); + assertAssetDirectory(root, rootStat); + + const files = []; + async function visit(directory, pathPrefix) { + const entries = await readdir(directory, { withFileTypes: true }); + entries.sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + ); + for (const entry of entries) { + const absolute = join(directory, entry.name); + const path = portablePath(pathPrefix, entry.name); + if (entry.isSymbolicLink()) fail(`asset may not be a symlink: ${path}`); + if (entry.isDirectory()) { + await visit(absolute, path); + } else if (entry.isFile()) { + files.push({ absolute, path: portablePath(prefix, path) }); + } else { + fail(`asset must be a regular file: ${path}`); + } + } + } + await visit(root, ""); + return files; +} + +function assertPortableFileSet(files) { + const folded = new Map(); + for (const { path } of files) { + const segments = path.split("/"); + if ( + segments.length < 2 || + (segments[0] !== "web" && segments[0] !== "wasm") || + segments.some( + (segment) => + !PORTABLE_SEGMENT_PATTERN.test(segment) || + segment.endsWith(".") || + WINDOWS_DEVICE_PATTERN.test(segment), + ) + ) { + fail(`manifest path is not portable ASCII: ${JSON.stringify(path)}`); + } + const key = path.toLowerCase(); + const previous = folded.get(key); + if (previous !== undefined) { + fail(`case-insensitive path collision: ${previous} and ${path}`); + } + folded.set(key, path); + } +} + +async function copyFile(source, destination) { + const bytes = await readFile(source); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, bytes, { flag: "wx" }); +} + +async function sha256(path) { + const bytes = await readFile(path); + return createHash("sha256").update(bytes).digest("hex"); +} + +async function manifestEntry(root, path) { + const absolute = join(root, ...path.split("/")); + const metadata = await stat(absolute); + return { path, sha256: await sha256(absolute), size: metadata.size }; +} + +function assertProtocolMap(protocols) { + if (typeof protocols !== "object" || protocols === null || Array.isArray(protocols)) { + fail("protocols must be an object"); + } + const actualKeys = Object.keys(protocols).sort(); + const expectedKeys = Object.keys(PROTOCOLS).sort(); + if (JSON.stringify(actualKeys) !== JSON.stringify(expectedKeys)) { + fail(`protocol map keys differ from the ${SIDECAR_PROTOCOL} contract`); + } + for (const [name, version] of Object.entries(PROTOCOLS)) { + if (protocols[name] !== version) fail(`protocol ${name} must be ${version}`); + } +} + +async function assertWebBundle(root) { + const indexPath = join(root, "web", "index.html"); + const index = await readFile(indexPath, "utf8").catch(() => null); + if (index === null) fail("web/index.html is missing"); + if (Buffer.byteLength(index) <= 200) fail("web/index.html is trivially small"); + if (!//i.test(index)) fail("web/index.html has no HTML doctype"); + + const references = [...index.matchAll(/(?:src|href)=["']([^"']+)["']/gi)].map( + (match) => match[1], + ); + const localAssets = references + .filter((value) => !/^(?:[a-z][a-z0-9+.-]*:|\/|#)/i.test(value)) + .map((value) => decodeURIComponent(value.split(/[?#]/, 1)[0])) + .filter((value) => /\.(?:css|m?js)$/i.test(value)); + const rootedAssets = references + .filter((value) => value.startsWith("/") && !value.startsWith("//")) + .map((value) => decodeURIComponent(value.split(/[?#]/, 1)[0]).slice(1)) + .filter((value) => /\.(?:css|m?js)$/i.test(value)); + const assets = [...new Set([...localAssets, ...rootedAssets])]; + if (assets.length === 0) fail("web/index.html references no local JavaScript or CSS"); + for (const asset of assets) { + if (asset.includes("\\") || asset.split("/").some((part) => part === "..")) { + fail(`web/index.html contains an unsafe asset reference: ${asset}`); + } + const metadata = await stat(join(root, "web", ...asset.split("/"))).catch(() => null); + if (!metadata?.isFile() || metadata.size === 0) { + fail(`web/index.html references missing or empty asset: ${asset}`); + } + } + + const webFiles = await regularFiles(join(root, "web")); + const scripts = webFiles + .filter(({ path }) => /\.m?js$/i.test(path)) + .sort(comparePath); + const scriptText = (await Promise.all(scripts.map(({ absolute }) => readFile(absolute, "utf8")))).join( + "\n", + ); + for (const route of REQUIRED_ROUTES) { + if (!scriptText.includes(route)) fail(`Uhura web build does not reference route ${route}`); + } +} + +async function assertWasmBundle(root) { + for (const path of REQUIRED_WASM) { + const metadata = await stat(join(root, ...path.split("/"))).catch(() => null); + if (!metadata?.isFile() || metadata.size === 0) fail(`required artifact is missing: ${path}`); + } + const module = await open(join(root, "wasm", "uhura_wasm_bg.wasm"), "r"); + try { + const magic = Buffer.alloc(4); + const { bytesRead } = await module.read(magic, 0, magic.length, 0); + if (bytesRead !== 4 || !magic.equals(Buffer.from([0x00, 0x61, 0x73, 0x6d]))) { + fail("wasm/uhura_wasm_bg.wasm has invalid WebAssembly magic"); + } + } finally { + await module.close(); + } + const glue = await readFile(join(root, "wasm", "uhura_wasm.js"), "utf8"); + if (!glue.includes("uhura_wasm_bg.wasm")) { + fail("wasm/uhura_wasm.js does not load uhura_wasm_bg.wasm"); + } +} + +async function verify(rootArg) { + const root = resolve(rootArg); + const rawManifest = await readFile(join(root, "manifest.json"), "utf8").catch(() => null); + if (rawManifest === null) fail(`manifest is missing under ${root}`); + let manifest; + try { + manifest = JSON.parse(rawManifest); + } catch (error) { + fail(`manifest is not valid JSON: ${error.message}`); + } + if (manifest.protocol !== SIDECAR_PROTOCOL) { + fail(`manifest protocol must be ${SIDECAR_PROTOCOL}`); + } + const topLevelKeys = Object.keys(manifest).sort(); + const expectedTopLevelKeys = ["files", "protocol", "protocols", "spock_commit", "uhura_commit"]; + if (JSON.stringify(topLevelKeys) !== JSON.stringify(expectedTopLevelKeys)) { + fail("manifest top-level keys differ from the sidecar contract"); + } + if (!COMMIT_PATTERN.test(manifest.spock_commit ?? "")) fail("invalid spock_commit"); + if (!COMMIT_PATTERN.test(manifest.uhura_commit ?? "")) fail("invalid uhura_commit"); + assertProtocolMap(manifest.protocols); + if (!Array.isArray(manifest.files) || manifest.files.length === 0) { + fail("manifest files must be a non-empty array"); + } + + const actualFiles = [ + ...(await regularFiles(join(root, "web"), "web")), + ...(await regularFiles(join(root, "wasm"), "wasm")), + ].sort(comparePath); + assertPortableFileSet(actualFiles); + const expectedPaths = actualFiles.map(({ path }) => path); + const manifestPaths = manifest.files.map((entry) => entry?.path); + if (JSON.stringify(manifestPaths) !== JSON.stringify(expectedPaths)) { + fail("manifest file inventory is missing, extra, duplicated, or not sorted"); + } + + for (const entry of manifest.files) { + if ( + typeof entry !== "object" || + entry === null || + Array.isArray(entry) || + JSON.stringify(Object.keys(entry).sort()) !== JSON.stringify(["path", "sha256", "size"]) + ) { + fail("each manifest file must contain exactly path, sha256, and size"); + } + if (!Number.isSafeInteger(entry.size) || entry.size <= 0) { + fail(`invalid size for ${entry.path}`); + } + if (!HASH_PATTERN.test(entry.sha256 ?? "")) fail(`invalid sha256 for ${entry.path}`); + const actual = await manifestEntry(root, entry.path); + if (actual.size !== entry.size) fail(`size mismatch for ${entry.path}`); + if (actual.sha256 !== entry.sha256) fail(`sha256 mismatch for ${entry.path}`); + } + await assertWebBundle(root); + await assertWasmBundle(root); + const total = manifest.files.reduce((sum, entry) => sum + entry.size, 0); + process.stdout.write( + `verified ${SIDECAR_PROTOCOL}: ${manifest.files.length} files, ${total} bytes\n`, + ); +} + +async function assemble(values) { + const webDir = resolve(values.get("--web-dir")); + const wasmDir = resolve(values.get("--wasm-dir")); + const outDir = resolve(values.get("--out-dir")); + const spockCommit = values.get("--spock-commit"); + const uhuraCommit = values.get("--uhura-commit"); + if (!COMMIT_PATTERN.test(spockCommit)) fail("--spock-commit must be a 40-character hex SHA"); + if (!COMMIT_PATTERN.test(uhuraCommit)) fail("--uhura-commit must be a 40-character hex SHA"); + assertOutputDirectory(outDir); + for (const source of [webDir, wasmDir]) { + const fromOutput = relative(outDir, source); + if (fromOutput === "" || (!fromOutput.startsWith(`..${sep}`) && fromOutput !== "..")) { + fail("source directories may not be inside the output directory"); + } + } + + const sourceFiles = [ + ...(await regularFiles(webDir, "web")), + ...(await regularFiles(wasmDir, "wasm")), + ].sort(comparePath); + if (sourceFiles.length === 0) fail("source asset trees are empty"); + assertPortableFileSet(sourceFiles); + + await mkdir(dirname(outDir), { recursive: true }); + const staging = `${outDir}.tmp-${process.pid}-${Date.now()}`; + await rm(staging, { recursive: true, force: true }); + try { + await mkdir(staging, { recursive: false }); + for (const file of sourceFiles) { + await copyFile(file.absolute, join(staging, ...file.path.split("/"))); + } + const files = []; + for (const file of sourceFiles) files.push(await manifestEntry(staging, file.path)); + const manifest = { + protocol: SIDECAR_PROTOCOL, + spock_commit: spockCommit, + uhura_commit: uhuraCommit, + protocols: PROTOCOLS, + files, + }; + await writeFile(join(staging, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { + flag: "wx", + }); + await verify(staging); + await rm(outDir, { recursive: true, force: true }); + await rename(staging, outDir); + } catch (error) { + await rm(staging, { recursive: true, force: true }); + throw error; + } + process.stdout.write(`assembled ${outDir}\n`); +} + +async function main() { + const { command, values } = parseArgs(process.argv.slice(2)); + if (command === "assemble") { + await assemble(values); + } else if (command === "verify") { + await verify(values.get("--root")); + } else { + selfTest(); + } +} + +// Avoid executing when imported by a future test harness. +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + process.stderr.write(`${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..32ab20c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +# Exact framework build pin. This matches the Uhura submodule and is not a +# floating `stable` alias (RFD 0022 section 12.4). +channel = "1.92.0" +components = ["rustfmt", "clippy"] diff --git a/uhura b/uhura index 42ece8e..baa70ce 160000 --- a/uhura +++ b/uhura @@ -1 +1 @@ -Subproject commit 42ece8e3c44efe89d3c9417761504e7b190db230 +Subproject commit baa70cedb2a78d967c07f6627a7fbd7a14085665