diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..09ca1ec --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,88 @@ +name: Test + +permissions: + contents: read + +# Cross-platform verification. The extension launches build tools as child +# processes without a shell, so how a process starts differs per OS โ€” that +# cannot be verified on Linux alone, and the unit tests mock spawn entirely. +# +# GitHub-hosted runners (not the self-hosted Ubuntu pool) so macOS and Windows +# are actually exercised. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +jobs: + unit: + name: Unit (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node Environment + uses: ./.github/setup-node + + - name: Typecheck + run: pnpm run check-types + + - name: Lint + run: pnpm run lint + + - name: Unit tests + run: pnpm test + + integration: + name: Integration (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + steps: + - uses: actions/checkout@v6 + + - name: Setup Node Environment + uses: ./.github/setup-node + + # The fixtures are real FastEdge apps with the real toolchains as their + # own dependencies, installed the way a user's project would be โ€” this is + # what proves `spawn(process.execPath, [.js, ...])` starts on + # Windows, where .cmd shims cannot be spawned without a shell. + - name: Install fixture dependencies + run: pnpm run fixtures:install + + # wasip1 for fastedge-crate HTTP apps and CDN proxy-wasm apps; wasip2 for + # wstd apps, which rustConfigWasiTarget infers rather than reads. + - name: Install Rust wasm targets + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-wasip1, wasm32-wasip2 + + - name: Cache cargo registry and fixture target dirs + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + test/fixtures/rust-app/target + test/fixtures/rust-app-wasi-http/target + test/fixtures/rust-app-cdn/target + key: ${{ runner.os }}-cargo-${{ hashFiles('test/fixtures/*/Cargo.toml') }} + restore-keys: ${{ runner.os }}-cargo- + + - name: Integration tests (real builds) + run: pnpm run test:integration diff --git a/.gitignore b/.gitignore index fb55f9f..fa317e9 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,10 @@ js-extension/ # Dev tools .nx + +# Integration test fixtures build in place +test/fixtures/*/node_modules/ +test/fixtures/*/.fastedge-debug/ +test/fixtures/*/build/ +test/fixtures/*/target/ +test/fixtures/*/package-lock.json diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md index c8dc5c2..8c46f35 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -13,6 +13,101 @@ See `SEARCH_GUIDE.md` for more search patterns. --- +## [2026-08-25] - Security: OS command injection (CWE-78) in the compilers + +### Overview +An external report demonstrated arbitrary command execution from a malicious workspace. All three compilers spawned their build tool with a shell, so workspace-controlled values were parsed as shell syntax. A repo with `"main": "index.js; touch /tmp/PWNED #"` in package.json executed that command when the developer ran "Debug: FastEdge App (Package Entry)". + +No child process in the extension is spawned through a shell any more. + +### ๐ŸŽฏ What Was Completed + +#### 1. Shell removed from every compiler +- `jsBuild.ts` / `asBuild.ts`: dropped `shell: true`; dropped `npx` entirely +- `rustBuild.ts`: dropped `shell = isWindows ? "cmd.exe" : "sh"`; `cargo` is a native executable and resolves from PATH on Windows without a command interpreter +- Injection vectors closed: `package.json` `main` (the reported PoC), `.cargo/config.toml` `[build] target` (returned verbatim by `rustConfig.ts`, second vector), and workspace directory names reaching `--outFile` in `asBuild.ts` + +#### 2. `src/utils/resolveBin.ts` (new) +- Resolves a build tool's real JS entry from the project โ€” `createRequire(buildRoot/package.json)` โ†’ package `bin` field โ€” for launch via `spawn(process.execPath, [bin, ...args])` +- **Do not "fix" Windows with `npx.cmd`**: patched Node rejects `.bat`/`.cmd` without a shell with `EINVAL` (CVE-2024-27980); unpatched Node routes it via `cmd.exe` and re-opens argument injection +- Verified: esbuild leaves the runtime `createRequire(...).resolve()` intact in the bundled extension rather than binding it to the extension's own module graph + +#### 3. Trust-boundary validation +- `package.json` `main` must resolve inside the build root (`path.resolve` + `path.relative`), rejecting absolute paths and `../` traversal +- **Not** added: a wasip1/wasip2 allowlist for the Rust target. Once argv is literal it buys nothing and would break the documented `.cargo/config.toml` custom-target support + +#### 4. `error` handlers on all three spawns +- Without a shell a launch failure arrives as the child's `"error"` event, not exit code 127. Without a listener the build promise hangs forever. Every new spawn needs one. + +#### 5. `src/autorun/triggerFileHandler.ts` โ€” amplifier closed +- `ALLOWED_COMMANDS` trimmed to `fastedge.setup-codespace-secret`, the only command any producer writes (`fastedge-codespace/.devcontainer/start.sh`) +- Removed: `run-file` / `run-workspace` (reached the vulnerable build with no user click), `generate-mcp-json` (writes credentials), `reloadWindow` (no producer; reload loop = DoS), `generate-launch-json` (never a registered command โ€” the real id is `fastedge.init-workspace`) +- Deleted dead `checkForTriggerFile()` โ€” never called, so a committed trigger file has never executed on activation + +#### 6. `src/commands/mcpJson.ts` โ€” same class, weaker prerequisites +- `getPlatformDockerCommand()` โ†’ `getDockerCommand()`: docker invoked with an argv array, no `bash -c` / `cmd /c` wrapper, so the workspace path is no longer spliced into a shell string +- Env forwarding switched to bare `-e GCORE_API_KEY` / `-e GCORE_API_BASE` โ€” docker reads them from its own environment, which the MCP client supplies via the config's `env` block. Removed all platform branching + +#### 7. Builds that report success without producing a binary now fail +- `jsBuild.ts` / `asBuild.ts` reject when the tool exits 0 but no `.wasm` exists at the output path +- Found by the new cross-platform CI: **`fastedge-build` prints "Build success!!", exits 0, and writes nothing when `NODE_ENV=test`** โ€” which is exactly what vitest sets. Reproduced outside vitest; unset and `production` both build normally +- This is an SDK bug in `@gcoredev/fastedge-sdk-js` (reported separately), but the extension previously believed it and went on to debug a stale or missing binary. The JS error message names `NODE_ENV` when it is the cause +- Related, out of scope: `fastedge-build` itself spawns with `shell: true` (emits Node's DEP0190), one layer below this extension + +#### 8. Cross-platform CI (`.github/workflows/test.yml`) +- Before this, **no test job existed and every job ran on self-hosted Ubuntu**. The per-OS matrix in `create-release.yml` only packages VSIXs โ€” it builds on Ubuntu and stamps `--target`, so no non-Linux machine had ever executed this code +- Two jobs, each on `ubuntu-latest` / `macos-latest` / `windows-latest`: **unit** (typecheck, lint, mocked tests) and **integration** (real builds) +- The compilers do not import `vscode`, so integration tests need plain vitest โ€” no extension host, no xvfb +- Fixtures mirror the canonical SDK examples rather than minimal stubs; a stub without the wasi-shim `extends` or the `fastedge` proc macro compiles cleanly while a real app breaks: + +| Fixture | Shape | Covers | +|---------|-------|--------| +| `js-app` | HTTP, `fastedge-build` | JS toolchain launch | +| `as-app` | CDN proxy-wasm, `asc` | AssemblyScript toolchain launch | +| `rust-app` | HTTP, `fastedge` crate | wasip1 via explicit `.cargo/config.toml` | +| `rust-app-wasi-http` | HTTP, `wstd` crate | wasip2 **inferred** โ€” deliberately has no `.cargo/config.toml` | +| `rust-app-cdn` | CDN, `proxy-wasm` crate | `rustBuild`'s `filenames.length === 1` artifact selection | + +- `src/compiler/rustConfig.test.ts` covers target selection for every app shape plus custom targets and malformed configs โ€” the `wstd` โ†’ wasip2 inference had no test at all, despite feeding the `--target=` argument this patch changed +- **CI cannot cover**: Docker on Windows or macOS runners (no Linux containers), so `getDockerCommand()` is verified by argv-shape assertions only. "Does Docker Desktop for Windows forward a valueless `-e`" stays a manual pre-release check + +**Files Modified:** +- `src/compiler/jsBuild.ts`, `asBuild.ts`, `rustBuild.ts` - no shell; `process.execPath` launch; entry-point containment; error handlers; output verification +- `src/autorun/triggerFileHandler.ts` - allowlist trimmed; dead code removed +- `src/commands/mcpJson.ts` - argv-array docker command +- `tsconfig.json` - dropped vestigial `rootDir` (tsc never emits; esbuild builds) so `test/integration` is typechecked; `test/fixtures` excluded +- `package.json` - added `test:integration` and `fixtures:install` scripts +- `.gitignore` - fixture build artifacts +- `context/features/CROSS_PLATFORM.md` - previously prescribed `shell: true` as the correct pattern +- `context/architecture/EXTENSION_LIFECYCLE.md` - autorun was described as rebuild-on-file-change +- `context/features/COMPILER_SYSTEM.md`, `COMMANDS.md` - documented the `npx` invocation + +**Files Created:** +- `src/utils/resolveBin.ts` - project-local bin resolution +- `src/compiler/compilerSpawn.test.ts` - 6 regression tests +- `src/compiler/rustConfig.test.ts` - 8 target-selection tests +- `src/commands/mcpJson.test.ts` - 5 docker argv-shape tests +- `.github/workflows/test.yml` - cross-platform unit + integration matrix +- `test/integration/compilers.test.ts` - 5 real builds +- `test/fixtures/` - five FastEdge app fixtures + +### ๐Ÿงช Testing +`pnpm test` โ€” 40 unit tests. `pnpm run test:integration` โ€” 5 real builds (~19s locally), after `pnpm run fixtures:install`. Both run on Linux, macOS and Windows in CI. + +The spawn suite asserts, per compiler, that the command is `process.execPath` (or bare `cargo`), that argv[0] is the resolved bin, that `shell` is falsy, and that the payload survives as one literal argv element. Verified the guard bites: reinstating `shell: true` in `jsBuild.ts` fails the JS case. + +Asserting only "no shell + literal argv" is insufficient โ€” that passes for `spawn("npx.cmd", โ€ฆ, {shell:false})`, the exact implementation that breaks on Windows. The `process.execPath` assertion is what catches it. + +### ๐Ÿ“ Notes +**Behaviour changes:** +- Build tools must be local devDependencies. `npx` previously downloaded a missing package from the registry and ran it; that is gone. A missing tool now fails with an install instruction. +- Yarn Plug'n'Play is unsupported โ€” the dependency map lives in `.pnp.cjs`, which Node ignores unless preloaded. Supporting it means executing workspace JavaScript before the compiler starts. +- `getDockerCommand()` output is platform-independent; the "Generated mcp.json with configuration" message lost its platform name. + +**Known issue, deliberately not fixed here:** `resolveAppRoot.ts` and `rustConfig.ts` walk to the filesystem root, so an ancestor `package.json` / `Cargo.toml` / `.cargo/config.toml` *outside* the VS Code workspace can become the build root. Plausibly intentional for nested monorepos โ€” needs a product decision, tracked separately. + +--- + ## [2026-05-21] - Unify on GCORE_API_KEY โ€” remove GCORE_API_TOKEN ### Overview diff --git a/context/architecture/EXTENSION_LIFECYCLE.md b/context/architecture/EXTENSION_LIFECYCLE.md index cc0a2f1..e730259 100644 --- a/context/architecture/EXTENSION_LIFECYCLE.md +++ b/context/architecture/EXTENSION_LIFECYCLE.md @@ -154,11 +154,20 @@ vscode.workspace.getConfiguration('fastedge').update( ### Event Handling -**File watching** (autorun feature): +**Trigger file watching** (autorun feature): - `src/autorun/triggerFileHandler.ts` -- Watches files for changes -- Can trigger rebuild/rerun automatically -- Registered if autorun is enabled +- Registered unconditionally at activation โ€” there is no "autorun enabled" setting +- Watches one path only: `.vscode/.fastedge-run-command` +- Not a rebuild-on-file-change feature. It is a bootstrap hook so an external + process can drive a VS Code command that a shell script cannot: the + `fastedge-codespace` devcontainer writes the file from its `postAttachCommand`, + then polls for the resulting Codespace secret +- Fires on create/change only, so a file already committed in a repo does **not** + execute when the workspace is opened +- The file is workspace-controlled, so `ALLOWED_COMMANDS` is deliberately a + single entry (`fastedge.setup-codespace-secret`). Do not add build, config- + generating, or window-reloading commands to it โ€” the rationale for each + removal is in the `ALLOWED_COMMANDS` comment in `triggerFileHandler.ts` **Configuration changes**: - Extension can react to settings changes via `vscode.workspace.onDidChangeConfiguration` diff --git a/context/features/COMMANDS.md b/context/features/COMMANDS.md index 54e560a..021b43b 100644 --- a/context/features/COMMANDS.md +++ b/context/features/COMMANDS.md @@ -55,7 +55,7 @@ Builds the **active editor file** as the WASM entry point, starts a per-app debu **Rust**: `cargo build --target wasm32-wasip1` from `buildRoot` (nearest `Cargo.toml`) -**JavaScript**: `npx fastedge-build ` from `buildRoot` +**JavaScript**: ` ` from `buildRoot` (no shell, no `npx` โ€” see `CROSS_PLATFORM.md`) **AssemblyScript**: `asc assembly/index.ts` from `buildRoot` @@ -87,7 +87,7 @@ Use this when you're editing a helper file (e.g. `src/utils/headers.js`) but wan ### Behavior by Language -**JavaScript**: `npx fastedge-build ` from `buildRoot` +**JavaScript**: ` ` from `buildRoot`. `main` must resolve inside the build root or the build is rejected **Rust / AssemblyScript**: Identical to `run-file` โ€” `debugContext` is ignored. diff --git a/context/features/COMPILER_SYSTEM.md b/context/features/COMPILER_SYSTEM.md index 68ee3f2..87aa3f0 100644 --- a/context/features/COMPILER_SYSTEM.md +++ b/context/features/COMPILER_SYSTEM.md @@ -132,7 +132,7 @@ npm install --save-dev @gcoredev/fastedge-sdk-js 4. Determine entry point: - **File mode**: active file path - **Workspace mode**: `package.json` `main` field resolved relative to `buildRoot` -5. Spawn `npx fastedge-build /.fastedge-debug/app.wasm` at `buildRoot` +5. Resolve the `fastedge-build` bin from the project (`utils/resolveBin.ts`) and spawn `process.execPath /.fastedge-debug/app.wasm` at `buildRoot` โ€” argv array, no shell ### Entrypoint Modes @@ -160,7 +160,7 @@ Used for **CDN/Proxy-WASM applications** (HTTP request/response manipulation via npm install --save-dev assemblyscript @assemblyscript/wasi-shim ``` -The `asc` compiler is provided by the `assemblyscript` package โ€” no global install needed; `npx asc` resolves it from `node_modules`. +The `asc` compiler is provided by the `assemblyscript` package โ€” no global install needed. It must be a local devDependency: the extension resolves `bin.asc` from the project and runs it with the VS Code Node runtime. Nothing is downloaded on demand. ### Project Structure @@ -202,7 +202,7 @@ my-app/ 2. Verify `asconfig.json` exists at `buildRoot` โ€” throws if missing 3. Resolve `configRoot` (falls back to `buildRoot`) 4. Create `/.fastedge-debug/` directory -5. Spawn: `npx asc assembly/index.ts --target release --outFile /.fastedge-debug/app.wasm` at `buildRoot` +5. Resolve the `asc` bin from the project and spawn `process.execPath assembly/index.ts --target release --outFile /.fastedge-debug/app.wasm` at `buildRoot` โ€” argv array, no shell The `--target release` flag picks up optimization settings from `asconfig.json` (shrink level, no-assert, etc.). `--outFile` overrides only the output path to the standard debugger location. @@ -254,8 +254,8 @@ All three compilers write to the same path: | Language | Build mode | Incremental | |---|---|---| | Rust | `cargo build` (debug) | Yes โ€” Cargo caches in `target/` | -| JavaScript | `npx fastedge-build` | No โ€” rebuilds from scratch | -| AssemblyScript | `npx asc --target release` | No โ€” rebuilds from scratch | +| JavaScript | `fastedge-build` (resolved bin, run via `process.execPath`) | No โ€” rebuilds from scratch | +| AssemblyScript | `asc --target release` (resolved bin, run via `process.execPath`) | No โ€” rebuilds from scratch | AssemblyScript always builds in release mode because the AS `--target release` settings in `asconfig.json` are what produce a valid proxy-wasm binary. Debug builds may produce larger output but are otherwise equivalent for local testing. diff --git a/context/features/CROSS_PLATFORM.md b/context/features/CROSS_PLATFORM.md index f09534c..929e182 100644 --- a/context/features/CROSS_PLATFORM.md +++ b/context/features/CROSS_PLATFORM.md @@ -18,28 +18,31 @@ Platform detection in TypeScript: use `os.platform()` or `process.platform`. Bot ## Platform-Specific Code -### Rust compilation โ€” `src/compiler/rustBuild.ts` +**No child process in this extension is spawned through a shell.** Shell invocation made workspace-controlled values (`package.json` `main`, `.cargo/config.toml` target, directory names) executable โ€” see the CWE-78 entry in `CHANGELOG.md`. Every spawn uses an argv array. -Shell is selected based on platform before spawning cargo: +### Rust compilation โ€” `src/compiler/rustBuild.ts` -```typescript -const isWindows = os.platform() === "win32"; -const shell = isWindows ? "cmd.exe" : "sh"; -spawn("cargo", [...], { shell, ... }); -``` +`spawn("cargo", [...])` with no `shell` option and no platform branching. `cargo` is a native executable, so Windows resolves `cargo.exe` from PATH without a command interpreter. ### JS / AssemblyScript compilation โ€” `src/compiler/jsBuild.ts`, `asBuild.ts` -Both use `shell: true`, which delegates to the system default shell on every platform (cmd.exe on Windows, sh on Unix). No explicit branching needed. +Neither uses `npx`. `utils/resolveBin.ts` resolves the tool's real JS entry point from the project (`createRequire` from `buildRoot` โ†’ the package's `bin` field), and it is launched as `spawn(process.execPath, [binPath, ...args])`. + +Do **not** "fix" Windows by spawning `npx.cmd`: patched Node rejects `.bat`/`.cmd` without a shell with `EINVAL` (CVE-2024-27980), and unpatched Node routes it through `cmd.exe` and re-opens argument injection. + +Consequences worth knowing: + +- The build tool must be a local devDependency. Nothing is downloaded โ€” a missing tool is a clear error, not a silent registry fetch. +- Yarn Plug'n'Play projects are unsupported: their dependency map lives in `.pnp.cjs`, which Node ignores unless preloaded. +- A launch failure now arrives as the child's `"error"` event, not exit code 127. All three compilers attach an `error` handler; a new spawn without one will hang forever. ### MCP Docker command generation โ€” `src/commands/mcpJson.ts` -`getPlatformDockerCommand()` branches on `os.platform()`: +`getDockerCommand()` does **not** branch on platform. `docker` is invoked directly with an argv array on every platform โ€” no `cmd /c` or `bash -c` wrapper, so the workspace path is never spliced into a shell string. -- **win32**: `cmd /c docker run ...` with `%VAR%` env var syntax. `--user` flag omitted (not supported on Docker Desktop for Windows). -- **linux / darwin**: `bash -c "docker run ..."` with `$VAR` env var syntax. `--user` flag included. +Environment variables are forwarded with bare `-e GCORE_API_KEY` / `-e GCORE_API_BASE`: docker reads them from its own environment, which the MCP client supplies via the config's `env` block. No `%VAR%` / `$VAR` expansion, so no shell is needed. -If you add new shell-invoked commands to mcp.json generation, follow the same branching pattern. +If you add new commands to mcp.json generation, keep them platform-independent argv arrays โ€” no shell, no branching. --- @@ -70,13 +73,17 @@ path.join(tmpdir(), "temp-file"); const tmp = "/tmp/temp-file"; ``` -### Process spawning โ€” pick the right shell strategy +### Process spawning โ€” never use a shell | Use case | Pattern | |----------|---------| -| `cargo`, `npx`, `asc` โ€” cross-platform CLI tools | `shell: true` or explicit `cmd.exe` / `sh` | +| Native executable (`cargo`, `docker`) | `spawn(name, argvArray)` โ€” no `shell`; Windows resolves the `.exe` from PATH | +| Node CLI tool from the user's project (`fastedge-build`, `asc`) | `resolvePackageBin()` โ†’ `spawn(process.execPath, [binPath, ...args])` | +| A `.cmd` / `.bat` shim, including `npx.cmd` | **Never.** Resolve the underlying JS entry point instead โ€” see `utils/resolveBin.ts` | | Shell syntax (`&&`, `|`, `&`) in the command string | **Dev scripts only** โ€” not in production code | -| Generating shell commands for config files | Branch on `os.platform()` โ€” see `mcpJson.ts` | +| Generating commands for config files | Emit an argv array, not a command string โ€” see `mcpJson.ts` | + +Anything reaching a child process argument may be workspace-controlled and attacker-authored. Keep it in argv, where metacharacters are inert. ### Process signals โ€” SIGTERM is unreliable on Windows @@ -103,9 +110,9 @@ The debugger server is forked with `process.execPath` (VSCode's embedded Node.js | VSIX platform targeting | `.github/workflows/build-extension.yml` โ€” `vsce package --target $os_target` | โœ… | | One binary per VSIX | `.github/workflows/download-debugger.yml` โ€” matrix strips other binaries | โœ… | | `chmod +x` on Unix, skip on Windows | download-debugger.yml matrix step | โœ… | -| Rust spawn shell (cmd vs sh) | `src/compiler/rustBuild.ts:16` | โœ… | -| JS/AS spawn | `src/compiler/jsBuild.ts`, `asBuild.ts` โ€” `shell: true` | โœ… | -| MCP Docker command | `src/commands/mcpJson.ts:getPlatformDockerCommand()` | โœ… | +| Rust spawn | `src/compiler/rustBuild.ts` โ€” bare `cargo`, argv array, no shell | โœ… | +| JS/AS spawn | `src/compiler/jsBuild.ts`, `asBuild.ts` โ€” `process.execPath` + resolved bin, no shell | โœ… | +| MCP Docker command | `src/commands/mcpJson.ts:getDockerCommand()` โ€” argv array, platform-independent | โœ… | | File path handling | Throughout โ€” `path.join()` and `vscode.Uri.joinPath()` | โœ… | | Server fork | `src/debugger/DebuggerServerManager.ts` โ€” `process.execPath` | โœ… | | Port discovery | `DebuggerServerManager.waitForPortFile()` โ€” reads port file written by fastedge-test, platform-agnostic | โœ… | diff --git a/package.json b/package.json index 0e4f5f5..fd8c1ad 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "build": "npm run check-types && node esbuild/build-ext.js --prod", "package": "vsce package --no-dependencies", "lint": "eslint src --ext ts", - "test": "vitest run src/**/*.test.ts" + "test": "vitest run src/**/*.test.ts", + "test:integration": "vitest run test/integration", + "fixtures:install": "npm --prefix test/fixtures/js-app install && npm --prefix test/fixtures/as-app install" }, "pnpm": { "onlyBuiltDependencies": [ @@ -54,7 +56,7 @@ "fastedge.apiUrl": { "type": "string", "default": "https://api.gcore.com", - "description": "Advanced override for the Gcore API base URL used by the FastEdge MCP server. Leave at the default for normal use. Set to a non-prod URL (e.g. https://api.preprod.world) for in-house development โ€” 'FastEdge (Generate mcp.json)' will emit it as GCORE_API_BASE. See DEVELOPMENT.md.", + "description": "Advanced override for the Gcore API base URL used by the FastEdge MCP server. Leave at the default for normal use. Set to a non-prod URL (e.g. https://api.preprod.world) for in-house development \u2014 'FastEdge (Generate mcp.json)' will emit it as GCORE_API_BASE. See DEVELOPMENT.md.", "scope": "application" } } diff --git a/src/autorun/triggerFileHandler.ts b/src/autorun/triggerFileHandler.ts index 99e7c42..9aeaf6a 100644 --- a/src/autorun/triggerFileHandler.ts +++ b/src/autorun/triggerFileHandler.ts @@ -4,16 +4,17 @@ import * as vscode from "vscode"; USAGE EXAMPLE FOR TRIGGER FILE (.vscode/.fastedge-run-command): ---------------------------------------- Simple command (no args): -fastedge.generate-launch-json +fastedge.setup-codespace-secret OR JSON format with args: { - "command": "fastedge.generate-mcp-json", + "command": "fastedge.setup-codespace-secret", "args": ["optionalArg1", 42] } ---------------------------------------- -e.g. to auto-generate launch.json on startup. -echo "fastedge.generate-launch-json" > .vscode/.fastedge-run-command +See ALLOWED_COMMANDS below for the full list of commands that can be +triggered this way. +echo "fastedge.setup-codespace-secret" > .vscode/.fastedge-run-command */ /** @@ -22,17 +23,21 @@ echo "fastedge.generate-launch-json" > .vscode/.fastedge-run-command const TRIGGER_FILE_PATH = ".vscode/.fastedge-run-command"; /** - * Allowlist of commands that can be executed via trigger file - * This is a security measure to prevent arbitrary command execution + * Allowlist of commands that can be executed via trigger file. + * + * The trigger file lives in the workspace, so anything listed here can be + * invoked without a click by whatever wrote it. Keep it to commands an actual + * producer needs: the only one is the devcontainer bootstrap in + * fastedge-codespace, which writes "fastedge.setup-codespace-secret" and then + * polls for the secret to appear. + * + * Deliberately removed: the build commands (they reach the compilers with + * workspace-controlled input), generate-mcp-json (writes credentials to disk), + * reloadWindow (no producer; a reload loop is a denial of service), and + * generate-launch-json (never a registered command โ€” the real id is + * fastedge.init-workspace). */ -const ALLOWED_COMMANDS = [ - "fastedge.setup-codespace-secret", - "fastedge.generate-launch-json", - "fastedge.generate-mcp-json", - "fastedge.run-file", - "fastedge.run-workspace", - "workbench.action.reloadWindow", -]; +const ALLOWED_COMMANDS = ["fastedge.setup-codespace-secret"]; /** * Command structure for JSON format @@ -74,37 +79,6 @@ export function initializeTriggerFileHandler( } } -/** - * Check for existing trigger file on activation - */ -async function checkForTriggerFile( - outputChannel: vscode.OutputChannel, -): Promise { - const workspaceFolders = vscode.workspace.workspaceFolders; - - if (!workspaceFolders || workspaceFolders.length === 0) { - outputChannel.appendLine( - "No workspace folder found, skipping trigger file check", - ); - return; - } - - // Check first workspace folder (most common case) - const triggerPath = vscode.Uri.joinPath( - workspaceFolders[0].uri, - TRIGGER_FILE_PATH, - ); - - try { - await vscode.workspace.fs.stat(triggerPath); - outputChannel.appendLine(`Found trigger file at: ${triggerPath.fsPath}`); - await executeTriggerFile(triggerPath, outputChannel); - } catch { - // File doesn't exist, that's fine - outputChannel.appendLine("No trigger file found on activation"); - } -} - /** * Execute command from trigger file */ diff --git a/src/commands/mcpJson.test.ts b/src/commands/mcpJson.test.ts new file mode 100644 index 0000000..07d4107 --- /dev/null +++ b/src/commands/mcpJson.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest"; + +// mcpJson pulls in the vscode module graph; only the pure command builder is +// under test here. +vi.mock("vscode", () => ({ workspace: {}, window: {}, Uri: {} })); + +import { getDockerCommand } from "./mcpJson"; + +// --------------------------------------------------------------------------- +// CI cannot run this end to end: GitHub's windows-latest and macos-latest +// runners have no Linux-container Docker, and the MCP server is a Linux image. +// So assert the generated argv shape here, and keep "does Docker Desktop for +// Windows forward a valueless -e" on the manual pre-release checklist. +// --------------------------------------------------------------------------- + +describe("getDockerCommand", () => { + it("invokes docker directly, with no shell wrapper", () => { + const { command, args } = getDockerCommand(false); + + expect(command).toBe("docker"); + // A bash -c / cmd /c wrapper is what spliced the workspace path into a + // shell command string. + expect(command).not.toMatch(/bash|sh|cmd/); + expect(args).not.toContain("-c"); + expect(args).not.toContain("/c"); + expect(args[0]).toBe("run"); + }); + + it("keeps the workspace path as one argv element", () => { + const { args } = getDockerCommand(false); + + const volume = args[args.indexOf("-v") + 1]; + expect(volume).toBe("${workspaceFolder}:/workspace"); + // No argument is a command string carrying the path plus other words. + expect(args.every((a) => !/\s/.test(a) || a === volume)).toBe(true); + }); + + it("forwards credentials by name, without shell expansion", () => { + const { args } = getDockerCommand(true); + + // Bare `-e NAME` โ€” docker reads the value from its own environment, which + // the MCP client supplies. `$VAR` / `%VAR%` would need a shell to expand. + expect(args).toContain("GCORE_API_KEY"); + expect(args).toContain("GCORE_API_BASE"); + + // `${workspaceFolder}` is a VS Code variable, expanded by VS Code before + // docker is launched โ€” it is not shell syntax. Nothing else may carry a + // shell-expandable reference. + const shellExpansions = args.filter( + (a) => a !== "${workspaceFolder}:/workspace" && /\$|%\w+%/.test(a), + ); + expect(shellExpansions).toEqual([]); + }); + + it("omits the API base override unless one is configured", () => { + expect(getDockerCommand(false).args).not.toContain("GCORE_API_BASE"); + }); + + it("is platform independent", () => { + const original = Object.getOwnPropertyDescriptor(process, "platform")!; + try { + const seen = new Set(); + for (const platform of ["win32", "darwin", "linux"]) { + Object.defineProperty(process, "platform", { value: platform }); + seen.add(JSON.stringify(getDockerCommand(true))); + } + expect(seen.size).toBe(1); + } finally { + Object.defineProperty(process, "platform", original); + } + }); +}); diff --git a/src/commands/mcpJson.ts b/src/commands/mcpJson.ts index 543e917..144fd18 100644 --- a/src/commands/mcpJson.ts +++ b/src/commands/mcpJson.ts @@ -1,46 +1,42 @@ import * as vscode from "vscode"; -import * as os from "os"; import { MCPConfiguration } from "../types"; import { isCodespace, setupCodespaceSecret } from "./codespaceSecrets"; const DEFAULT_API_URL = "https://api.gcore.com"; -function getPlatformDockerCommand(includeBaseOverride: boolean): { +/** + * Build the docker invocation for the generated mcp.json. + * + * Docker is launched directly with an argv array on every platform โ€” no + * `bash -c` / `cmd /c` wrapper. The wrapper meant the workspace path was + * spliced into a shell command string, so a path containing shell syntax + * changed what ran. It also required per-platform variable expansion + * (`$VAR` / `%VAR%`); a bare `-e NAME` makes docker forward the value from + * its own environment instead, which the MCP client supplies via the "env" + * block below. + */ +function getDockerCommand(includeBaseOverride: boolean): { command: string; args: string[]; } { - const platform = os.platform(); - - if (platform === "win32") { - // Windows - cmd, Windows-style env (%VAR%). No --user flag (Windows Docker Desktop). - const args = [ - "/c", - "docker", - "run", - "--rm", - "-i", - "--pull=always", - "-v", - "${workspaceFolder}:/workspace", - "-e", - "WORKSPACE_ROOT=/workspace", - "-e", - "GCORE_API_KEY=%GCORE_API_KEY%", - ]; - if (includeBaseOverride) { - args.push("-e", "GCORE_API_BASE=%GCORE_API_BASE%"); - } - args.push("ghcr.io/g-core/fastedge-mcp-server:latest"); - return { command: "cmd", args }; + const args = [ + "run", + "--rm", + "-i", + "--pull=always", + "-v", + "${workspaceFolder}:/workspace", + "-e", + "WORKSPACE_ROOT=/workspace", + "-e", + "GCORE_API_KEY", + ]; + if (includeBaseOverride) { + args.push("-e", "GCORE_API_BASE"); } - - // macOS and Linux - bash, Unix-style env ($VAR). - const dockerCmd = - 'docker run --rm -i --pull=always -v "${workspaceFolder}:/workspace" -e "WORKSPACE_ROOT=/workspace" -e "GCORE_API_KEY=$GCORE_API_KEY"' + - (includeBaseOverride ? ' -e "GCORE_API_BASE=$GCORE_API_BASE"' : "") + - " ghcr.io/g-core/fastedge-mcp-server:latest"; - return { command: "bash", args: ["-c", dockerCmd] }; + args.push("ghcr.io/g-core/fastedge-mcp-server:latest"); + return { command: "docker", args }; } async function addToGitignore(workspaceFolder: vscode.WorkspaceFolder) { @@ -285,14 +281,7 @@ async function createMCPJson(context?: vscode.ExtensionContext) { } } - // Get platform-specific Docker command - const dockerConfig = getPlatformDockerCommand(Boolean(apiBaseOverride)); - const platformName = - os.platform() === "win32" - ? "Windows" - : os.platform() === "darwin" - ? "macOS" - : "Linux"; + const dockerConfig = getDockerCommand(Boolean(apiBaseOverride)); const mcpJsonContent = { ...existingMCPJson, @@ -340,11 +329,11 @@ async function createMCPJson(context?: vscode.ExtensionContext) { } vscode.window.showInformationMessage( - `Generated mcp.json with ${platformName} configuration.`, + "Generated mcp.json.", ); } else { vscode.window.showErrorMessage("No workspace folder available."); } } -export { createMCPJson }; +export { createMCPJson, getDockerCommand }; diff --git a/src/compiler/asBuild.ts b/src/compiler/asBuild.ts index 3ead689..7e6e8e1 100644 --- a/src/compiler/asBuild.ts +++ b/src/compiler/asBuild.ts @@ -4,8 +4,11 @@ import path from "path"; import { LogToDebugConsole } from "../types"; import { resolveConfigRoot, resolveBuildRoot } from "../utils/resolveAppRoot"; +import { resolvePackageBin } from "../utils/resolveBin"; const BINARY_NAME = "app.wasm"; +const AS_PACKAGE = "assemblyscript"; +const AS_BIN = "asc"; const AS_ENTRY_POINT = path.join("assembly", "index.ts"); const makeDebugDirectory = (appRoot: string) => @@ -45,16 +48,32 @@ export function compileAssemblyScriptBinary( // Use --target release to pick up optimisation settings from asconfig.json, // but override --outFile to route output to the standard debugger location. + // Launched via process.execPath with an argv array โ€” never a shell. + // See utils/resolveBin.ts for why npx is not used. + const ascBin = resolvePackageBin(buildRoot, AS_PACKAGE, AS_BIN); const asBuild = spawn( - "npx", - ["asc", AS_ENTRY_POINT, "--target", "release", "--outFile", outFile], + process.execPath, + [ + ascBin, + AS_ENTRY_POINT, + "--target", + "release", + "--outFile", + outFile, + ], { - shell: true, stdio: ["ignore", "pipe", "pipe"], cwd: buildRoot, } ); + // Without a shell, a launch failure arrives as "error", not exit code 127. + asBuild.on("error", (err: Error) => + reject( + new Error(`Failed to start the AssemblyScript compiler: ${err.message}`) + ) + ); + let stderr = ""; asBuild.stdout?.on("data", (data: Buffer) => { @@ -70,6 +89,18 @@ export function compileAssemblyScriptBinary( reject(new Error(`asc build exited with code ${code}: ${stderr}`)); return; } + // A zero exit code is not proof of a binary โ€” see the equivalent check + // in jsBuild.ts. Resolving a path that does not exist sends the + // debugger off to load a stale binary instead of reporting the failure. + if (!fs.existsSync(outFile)) { + reject( + new Error( + `The asc build reported success but produced no binary at ${outFile}. ` + + "Check the build output above for the cause." + ) + ); + return; + } resolve(outFile); }); } catch (err) { diff --git a/src/compiler/compilerSpawn.test.ts b/src/compiler/compilerSpawn.test.ts new file mode 100644 index 0000000..21ef352 --- /dev/null +++ b/src/compiler/compilerSpawn.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { EventEmitter } from "events"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +// Must be hoisted above the compiler imports so they pick up the mock. +const spawnMock = vi.hoisted(() => vi.fn()); +vi.mock("child_process", () => ({ spawn: spawnMock })); + +import { compileJavascriptBinary } from "./jsBuild"; +import { compileAssemblyScriptBinary } from "./asBuild"; +import { compileRustAndFindBinary } from "./rustBuild"; + +// --------------------------------------------------------------------------- +// The payload from the vulnerability report. With shell: true this executed; +// every assertion below exists to prove it now arrives as inert argv data. +// --------------------------------------------------------------------------- +const PAYLOAD = "index.js; touch /tmp/VSCODE_PWNED #"; + +const noop = () => {}; + +/** + * Stand in for a build tool that succeeds: write the .wasm it was asked for, + * then exit 0. The compilers reject a zero exit that produced no binary, so a + * mock that only exits 0 is not a faithful success. + */ +function fakeSuccessfulBuild(args: string[]) { + const outFile = (args ?? []).find((a) => typeof a === "string" && a.endsWith(".wasm")); + if (outFile) { + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync(outFile, Buffer.from([0x00, 0x61, 0x73, 0x6d])); + } + return fakeChild(); +} + +function fakeChild(exitCode = 0) { + const child = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + }; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + setImmediate(() => child.emit("close", exitCode)); + return child; +} + +/** Minimal project with a locally installed build tool. */ +function mkProject(pkg: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fastedge-spawn-")); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(pkg)); + return root; +} + +function installTool(root: string, name: string, binName: string): string { + const pkgDir = path.join(root, "node_modules", ...name.split("/")); + fs.mkdirSync(path.join(pkgDir, "bin"), { recursive: true }); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name, version: "1.0.0", bin: { [binName]: `./bin/${binName}.js` } }), + ); + const binPath = path.join(pkgDir, "bin", `${binName}.js`); + fs.writeFileSync(binPath, ""); + return binPath; +} + +let tmpRoots: string[] = []; +afterEach(() => { + tmpRoots.forEach((d) => fs.rmSync(d, { recursive: true, force: true })); + tmpRoots = []; +}); +beforeEach(() => spawnMock.mockReset()); + +describe("compiler spawns are not shell-parsed", () => { + it("javascript: package.json main payload stays one literal argv element", async () => { + const root = mkProject({ name: "pwn", main: PAYLOAD }); + tmpRoots.push(root); + const binPath = installTool(root, "@gcoredev/fastedge-sdk-js", "fastedge-build"); + spawnMock.mockImplementation((...call: unknown[]) => + fakeSuccessfulBuild(call[1] as string[]), + ); + + await compileJavascriptBinary(path.join(root, "index.js"), "workspace", noop); + + const [command, args, options] = spawnMock.mock.calls[0]; + // Not npx, and not npx.cmd โ€” patched Node rejects .cmd without a shell. + expect(command).toBe(process.execPath); + expect(args[0]).toBe(binPath); + expect(options.shell).toBeFalsy(); + // The payload is one argument, still carrying its metacharacters, unexecuted. + expect(args[1]).toBe(path.join(root, PAYLOAD)); + expect(args.filter((a: string) => a.includes("touch"))).toHaveLength(1); + }); + + it("assemblyscript: metacharacters in the output path stay one literal argv element", async () => { + const root = mkProject({ name: "as-app" }); + tmpRoots.push(root); + // A directory name a hostile repo can commit. + const appDir = path.join(root, 'app"; touch /tmp/PWNED; #'); + fs.mkdirSync(path.join(appDir, ".fastedge-debug"), { recursive: true }); + fs.writeFileSync(path.join(appDir, "package.json"), "{}"); + fs.writeFileSync(path.join(appDir, "asconfig.json"), "{}"); + const binPath = installTool(root, "assemblyscript", "asc"); + // The tool resolves from the nested build root, so install it there too. + fs.cpSync(path.join(root, "node_modules"), path.join(appDir, "node_modules"), { + recursive: true, + }); + spawnMock.mockImplementation((...call: unknown[]) => + fakeSuccessfulBuild(call[1] as string[]), + ); + + await compileAssemblyScriptBinary(path.join(appDir, "index.ts"), noop); + + const [command, args, options] = spawnMock.mock.calls[0]; + expect(command).toBe(process.execPath); + expect(path.basename(args[0])).toBe(path.basename(binPath)); + expect(options.shell).toBeFalsy(); + const outFile = args[args.indexOf("--outFile") + 1]; + expect(outFile).toContain('"; touch'); + expect(outFile).toBe(path.join(appDir, ".fastedge-debug", "app.wasm")); + }); + + it("rust: a .cargo/config.toml target stays one literal argv element", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fastedge-spawn-")); + tmpRoots.push(root); + fs.writeFileSync(path.join(root, "Cargo.toml"), "[package]\nname='x'\n"); + fs.mkdirSync(path.join(root, ".cargo"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".cargo", "config.toml"), + '[build]\ntarget = "wasm32-wasip1; touch /tmp/PWNED"\n', + ); + spawnMock.mockImplementation(() => fakeChild(1)); + + await expect( + compileRustAndFindBinary(path.join(root, "src", "lib.rs"), noop), + ).rejects.toThrow(); + + const [command, args, options] = spawnMock.mock.calls[0]; + expect(command).toBe("cargo"); + expect(options.shell).toBeFalsy(); + expect(args).toContain("--target=wasm32-wasip1; touch /tmp/PWNED"); + }); +}); + +describe("package.json main containment", () => { + it("rejects a main field that escapes the build root", async () => { + const root = mkProject({ name: "pwn", main: "../../../../etc/passwd" }); + tmpRoots.push(root); + installTool(root, "@gcoredev/fastedge-sdk-js", "fastedge-build"); + + await expect( + compileJavascriptBinary(path.join(root, "index.js"), "workspace", noop), + ).rejects.toThrow(/resolves outside the project/); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it("rejects when the build exits 0 but produces no binary", async () => { + // fastedge-build does exactly this when NODE_ENV=test: prints + // "Build success!!", exits 0, writes nothing. Resolving here would send the + // debugger off to load a stale binary instead of surfacing the failure. + const root = mkProject({ name: "app", main: "index.js" }); + tmpRoots.push(root); + installTool(root, "@gcoredev/fastedge-sdk-js", "fastedge-build"); + spawnMock.mockImplementation(() => fakeChild(0)); + + await expect( + compileJavascriptBinary(path.join(root, "index.js"), "workspace", noop), + ).rejects.toThrow(/reported success but produced no binary/); + }); + + it("fails with an actionable message when the build tool is not installed", async () => { + const root = mkProject({ name: "app", main: "index.js" }); + tmpRoots.push(root); + + await expect( + compileJavascriptBinary(path.join(root, "index.js"), "workspace", noop), + ).rejects.toThrow(/Add "@gcoredev\/fastedge-sdk-js" as a development dependency/); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/compiler/jsBuild.ts b/src/compiler/jsBuild.ts index d03ab39..cf75646 100644 --- a/src/compiler/jsBuild.ts +++ b/src/compiler/jsBuild.ts @@ -4,8 +4,11 @@ import path from "path"; import { DebugContext, LogToDebugConsole } from "../types"; import { resolveConfigRoot, resolveBuildRoot } from "../utils/resolveAppRoot"; +import { resolvePackageBin } from "../utils/resolveBin"; const BINARY_NAME = "app.wasm"; +const SDK_PACKAGE = "@gcoredev/fastedge-sdk-js"; +const BUILD_BIN = "fastedge-build"; const makeDebugDirectory = (appRoot: string) => new Promise((resolve, reject) => { @@ -16,7 +19,7 @@ const makeDebugDirectory = (appRoot: string) => }); const getPackageJsonEntryPoint = (appRoot: string) => - new Promise((resolve, reject) => { + new Promise((resolve, reject) => { fs.readFile( path.join(appRoot, "package.json"), "utf8", @@ -35,6 +38,32 @@ const getPackageJsonEntryPoint = (appRoot: string) => ); }); +/** + * Resolve the `main` field of the project's package.json to an entry point + * inside the build root. + * + * `main` is workspace-controlled, so it is a trust boundary: reject values that + * escape the project (absolute paths, `../` traversal) rather than pointing the + * compiler at arbitrary files on the developer's machine. + */ +const resolvePackageEntryPoint = (buildRoot: string, mainField: unknown) => { + if (typeof mainField !== "string" || !mainField.trim()) { + throw new Error( + 'No "main" entry point found in package.json. Add a "main" field pointing at your app entry file.', + ); + } + + const entryPoint = path.resolve(buildRoot, mainField); + const relative = path.relative(buildRoot, entryPoint); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error( + `The "main" field in package.json ("${mainField}") resolves outside the project. ` + + "Use a path inside the project directory.", + ); + } + return entryPoint; +}; + export function compileJavascriptBinary( activeFilePath: string, debugContext: DebugContext, @@ -59,18 +88,28 @@ export function compileJavascriptBinary( const jsEntryPoint = debugContext === "file" ? activeFilePath - : path.join(buildRoot, await getPackageJsonEntryPoint(buildRoot)); + : resolvePackageEntryPoint( + buildRoot, + await getPackageJsonEntryPoint(buildRoot) + ); + // Launched via process.execPath with an argv array โ€” never a shell. + // See utils/resolveBin.ts for why npx is not used. + const buildBin = resolvePackageBin(buildRoot, SDK_PACKAGE, BUILD_BIN); const jsBuild = spawn( - "npx", - ["fastedge-build", jsEntryPoint, `${binPath}/${BINARY_NAME}`], + process.execPath, + [buildBin, jsEntryPoint, `${binPath}/${BINARY_NAME}`], { - shell: true, stdio: ["ignore", "pipe", "pipe"], cwd: buildRoot, } ); + // Without a shell, a launch failure arrives as "error", not exit code 127. + jsBuild.on("error", (err: Error) => + reject(new Error(`Failed to start the FastEdge build: ${err.message}`)) + ); + let stdout = ""; let stderr = ""; @@ -88,7 +127,23 @@ export function compileJavascriptBinary( reject(new Error(`build exited with code ${code}: ${stderr}`)); return; } - resolve(`${binPath}/${BINARY_NAME}`); + // A zero exit code is not proof of a binary. fastedge-build reports + // "Build success!!" and exits 0 while writing nothing when NODE_ENV is + // set to "test". Without this check the debugger goes on to load a + // stale binary, or none at all, and the real failure stays invisible. + const outputPath = `${binPath}/${BINARY_NAME}`; + if (!fs.existsSync(outputPath)) { + reject( + new Error( + `The build reported success but produced no binary at ${outputPath}. ` + + (process.env.NODE_ENV === "test" + ? 'NODE_ENV is set to "test", which makes fastedge-build skip the build silently. Unset it and retry.' + : "Check the build output above for the cause.") + ) + ); + return; + } + resolve(outputPath); }); } catch (err) { reject(err); diff --git a/src/compiler/rustBuild.ts b/src/compiler/rustBuild.ts index 00a045c..a4f5e3a 100644 --- a/src/compiler/rustBuild.ts +++ b/src/compiler/rustBuild.ts @@ -1,5 +1,4 @@ import { spawn } from "child_process"; -import * as os from "os"; import * as fs from "fs"; import * as path from "path"; import { LogToDebugConsole } from "../types"; @@ -12,8 +11,6 @@ export function compileRustAndFindBinary( ) { return new Promise(async (resolve, reject) => { logDebugConsole("Compiling Rust binary...\n"); - const isWindows = os.platform() === "win32"; - const shell = isWindows ? "cmd.exe" : "sh"; const buildRoot = resolveBuildRoot(activeFilePath); if (!buildRoot) { @@ -32,16 +29,27 @@ export function compileRustAndFindBinary( const target = rustConfigWasiTarget(logDebugConsole, activeFilePath); logDebugConsole("wasm build target: " + target + "\n", "stderr"); + // No shell: `target` comes from the workspace's .cargo/config.toml, and an + // argv array keeps it a literal argument. cargo is a native executable, so + // Windows resolves cargo.exe from PATH without a command interpreter. const cargoBuild = spawn( "cargo", ["build", "--message-format=json", `--target=${target}`], { - shell, stdio: ["ignore", "pipe", "pipe"], cwd: buildRoot, } ); + // Without a shell, a launch failure arrives as "error", not exit code 127. + cargoBuild.on("error", (err: Error) => + reject( + new Error( + `Failed to start cargo: ${err.message}. Install Rust and ensure "cargo" is on the PATH used to launch VS Code.` + ) + ) + ); + let stdout = ""; let stderr = ""; diff --git a/src/compiler/rustConfig.test.ts b/src/compiler/rustConfig.test.ts new file mode 100644 index 0000000..3cab389 --- /dev/null +++ b/src/compiler/rustConfig.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, afterEach } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; + +import { rustConfigWasiTarget } from "./rustConfig"; + +// --------------------------------------------------------------------------- +// The value this returns becomes the `--target=` argument passed to cargo, so +// every FastEdge Rust app shape needs to land on the right target. Cheap to +// cover exhaustively here; the integration suite then proves two of these +// actually build. +// --------------------------------------------------------------------------- + +const noop = () => {}; +let tmpRoots: string[] = []; + +afterEach(() => { + tmpRoots.forEach((d) => fs.rmSync(d, { recursive: true, force: true })); + tmpRoots = []; +}); + +function mkProject(cargoToml: string, cargoConfig?: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "fastedge-rustcfg-")); + tmpRoots.push(root); + fs.mkdirSync(path.join(root, "src"), { recursive: true }); + fs.writeFileSync(path.join(root, "Cargo.toml"), cargoToml); + if (cargoConfig !== undefined) { + fs.mkdirSync(path.join(root, ".cargo"), { recursive: true }); + fs.writeFileSync(path.join(root, ".cargo", "config.toml"), cargoConfig); + } + return path.join(root, "src", "lib.rs"); +} + +const HTTP_BASIC = '[package]\nname="x"\n\n[dependencies]\nfastedge = "0.4"\nanyhow = "1"\n'; +const HTTP_WASI = '[package]\nname="x"\n\n[dependencies]\nwstd = "0.6"\nanyhow = "1"\n'; +const CDN_PROXY_WASM = '[package]\nname="x"\n\n[dependencies]\nproxy-wasm = "0.2"\n'; + +describe("rustConfigWasiTarget", () => { + it("infers wasip1 for an HTTP app using the fastedge crate", () => { + expect(rustConfigWasiTarget(noop, mkProject(HTTP_BASIC))).toBe("wasm32-wasip1"); + }); + + it("infers wasip2 for an HTTP app using wstd", () => { + expect(rustConfigWasiTarget(noop, mkProject(HTTP_WASI))).toBe("wasm32-wasip2"); + }); + + it("infers wasip2 when a wasi app also depends on fastedge", () => { + const mixed = '[package]\nname="x"\n\n[dependencies]\nwstd = "0.6"\nfastedge = "0.4"\n'; + expect(rustConfigWasiTarget(noop, mkProject(mixed))).toBe("wasm32-wasip2"); + }); + + it("infers wasip1 for a CDN proxy-wasm app", () => { + expect(rustConfigWasiTarget(noop, mkProject(CDN_PROXY_WASM))).toBe("wasm32-wasip1"); + }); + + it("lets an explicit .cargo/config.toml target win over inference", () => { + const target = rustConfigWasiTarget( + noop, + mkProject(HTTP_WASI, '[build]\ntarget = "wasm32-wasip1"\n'), + ); + expect(target).toBe("wasm32-wasip1"); + }); + + it("honours a custom target from .cargo/config.toml", () => { + // Not restricted to a wasip1/wasip2 allowlist: cargo supports custom + // targets, and an argv array makes the string inert regardless. + const target = rustConfigWasiTarget( + noop, + mkProject(HTTP_BASIC, '[build]\ntarget = "my-custom-target"\n'), + ); + expect(target).toBe("my-custom-target"); + }); + + it("falls back to wasip1 when Cargo.toml is unparseable", () => { + expect(rustConfigWasiTarget(noop, mkProject("not [valid toml"))).toBe( + "wasm32-wasip1", + ); + }); + + it("falls back to inference when .cargo/config.toml is unparseable", () => { + expect(rustConfigWasiTarget(noop, mkProject(HTTP_WASI, "not [valid"))).toBe( + "wasm32-wasip2", + ); + }); +}); diff --git a/src/utils/resolveBin.ts b/src/utils/resolveBin.ts new file mode 100644 index 0000000..a923bc0 --- /dev/null +++ b/src/utils/resolveBin.ts @@ -0,0 +1,53 @@ +import { createRequire } from "node:module"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +/** + * Resolve an npm package's bin script from inside the user's project. + * + * Build tools are launched as `process.execPath ...` rather than + * `npx ` through a shell. A shell made workspace-controlled values + * (package.json `main`, `.cargo/config.toml` target, directory names) + * executable, and the obvious Windows workaround โ€” spawning `npx.cmd` โ€” is + * rejected by patched Node with EINVAL (CVE-2024-27980) and re-opens argument + * injection on unpatched Node. + * + * The tool must be a local dependency of the project. Nothing is downloaded: + * `npx` would fetch a missing package from the registry and execute it, which + * is neither reproducible nor safe to do on the user's behalf. + * + * Note: Yarn Plug'n'Play is unsupported โ€” its dependency map lives in + * .pnp.cjs, which Node ignores unless preloaded. Supporting it means executing + * workspace JavaScript before the compiler starts; revisit only if a real + * project needs it. + */ +export function resolvePackageBin( + buildRoot: string, + packageName: string, + binName: string, +): string { + const requireFromProject = createRequire(path.join(buildRoot, "package.json")); + + let packageJsonPath: string; + try { + packageJsonPath = requireFromProject.resolve(`${packageName}/package.json`); + } catch { + throw new Error( + `${binName} not found in this project. Add "${packageName}" as a development ` + + `dependency and retry (npm install --save-dev ${packageName}). The extension ` + + `does not download build tools automatically, and does not support Yarn ` + + `Plug'n'Play projects.`, + ); + } + + const { bin } = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + const binRelativePath = typeof bin === "string" ? bin : bin?.[binName]; + if (!binRelativePath) { + throw new Error( + `"${packageName}" does not provide a "${binName}" executable. ` + + `Check the installed version.`, + ); + } + + return path.resolve(path.dirname(packageJsonPath), binRelativePath); +} diff --git a/test/fixtures/as-app/asconfig.json b/test/fixtures/as-app/asconfig.json new file mode 100644 index 0000000..1c5a37d --- /dev/null +++ b/test/fixtures/as-app/asconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "./node_modules/@assemblyscript/wasi-shim/asconfig.json", + "targets": { + "debug": { + "outFile": "build/fixture-debug.wasm", + "sourceMap": true, + "debug": true + }, + "release": { + "outFile": "build/fixture.wasm", + "optimizeLevel": 3, + "shrinkLevel": 0, + "converge": false, + "noAssert": false + } + }, + "options": { + "bindings": "esm", + "use": "abort=abort_proc_exit" + } +} diff --git a/test/fixtures/as-app/assembly/index.ts b/test/fixtures/as-app/assembly/index.ts new file mode 100644 index 0000000..dec86c1 --- /dev/null +++ b/test/fixtures/as-app/assembly/index.ts @@ -0,0 +1,37 @@ +// Minimal FastEdge CDN app. Mirrors proxy-wasm-sdk-as/examples/helloWorld so +// the build exercises the real toolchain: the proxy-wasm SDK, the wasi-shim +// asconfig `extends`, and the `abort=abort_proc_exit` binding. A fixture +// without those compiles cleanly while a real app breaks. +export * from "@gcoredev/proxy-wasm-sdk-as/assembly/proxy"; +import { + Context, + FilterHeadersStatusValues, + log, + LogLevelValues, + registerRootContext, + RootContext, +} from "@gcoredev/proxy-wasm-sdk-as/assembly"; + +class FixtureRoot extends RootContext { + createContext(context_id: u32): Context { + return new Fixture(context_id, this); + } +} + +class Fixture extends Context { + constructor(context_id: u32, root_context: FixtureRoot) { + super(context_id, root_context); + } + + onRequestHeaders( + headers: u32, + end_of_stream: bool, + ): FilterHeadersStatusValues { + log(LogLevelValues.info, "onRequestHeaders >> fixture"); + return FilterHeadersStatusValues.Continue; + } +} + +registerRootContext((context_id: u32) => { + return new FixtureRoot(context_id); +}, "fixture"); diff --git a/test/fixtures/as-app/package.json b/test/fixtures/as-app/package.json new file mode 100644 index 0000000..80d1d7d --- /dev/null +++ b/test/fixtures/as-app/package.json @@ -0,0 +1,13 @@ +{ + "name": "fastedge-fixture-as-app", + "private": true, + "version": "1.0.0", + "description": "Minimal FastEdge CDN app โ€” mirrors proxy-wasm-sdk-as/examples/helloWorld", + "dependencies": { + "@gcoredev/proxy-wasm-sdk-as": "^1.2.3" + }, + "devDependencies": { + "@assemblyscript/wasi-shim": "^0.1.0", + "assemblyscript": "^0.28.9" + } +} diff --git a/test/fixtures/as-app/tsconfig.json b/test/fixtures/as-app/tsconfig.json new file mode 100644 index 0000000..798b474 --- /dev/null +++ b/test/fixtures/as-app/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "assemblyscript/std/assembly.json", + "include": ["./**/*.ts"] +} diff --git a/test/fixtures/js-app/index.js b/test/fixtures/js-app/index.js new file mode 100644 index 0000000..a395b69 --- /dev/null +++ b/test/fixtures/js-app/index.js @@ -0,0 +1,7 @@ +async function eventHandler(event) { + return new Response(`fixture ok: ${event.request.url}`, { status: 200 }); +} + +addEventListener("fetch", (event) => { + event.respondWith(eventHandler(event)); +}); diff --git a/test/fixtures/js-app/package.json b/test/fixtures/js-app/package.json new file mode 100644 index 0000000..2e51297 --- /dev/null +++ b/test/fixtures/js-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "fastedge-fixture-js-app", + "private": true, + "version": "1.0.0", + "main": "index.js", + "type": "module", + "devDependencies": { + "@gcoredev/fastedge-sdk-js": "^2.3.0" + } +} diff --git a/test/fixtures/rust-app-cdn/.cargo/config.toml b/test/fixtures/rust-app-cdn/.cargo/config.toml new file mode 100644 index 0000000..6b509f5 --- /dev/null +++ b/test/fixtures/rust-app-cdn/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/test/fixtures/rust-app-cdn/Cargo.lock b/test/fixtures/rust-app-cdn/Cargo.lock new file mode 100644 index 0000000..bc4780c --- /dev/null +++ b/test/fixtures/rust-app-cdn/Cargo.lock @@ -0,0 +1,55 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastedge-fixture-rust-cdn" +version = "0.1.0" +dependencies = [ + "proxy-wasm", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "proxy-wasm" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de8f6564bd52c2f4ff79fa5d1bd3bc10d8f822162af8d527e121e46703496aa0" +dependencies = [ + "hashbrown", + "log", +] diff --git a/test/fixtures/rust-app-cdn/Cargo.toml b/test/fixtures/rust-app-cdn/Cargo.toml new file mode 100644 index 0000000..7c7cb6b --- /dev/null +++ b/test/fixtures/rust-app-cdn/Cargo.toml @@ -0,0 +1,18 @@ +# Mirrors FastEdge-sdk-rust/examples/cdn/headers โ€” a CDN proxy-wasm app. +# +# Same cargo invocation as the wasip1 HTTP fixture, so this adds no launcher +# coverage. It is here for rustBuild's artifact selection: that code accepts a +# compiler-artifact message only when `filenames.length === 1`, and a different +# crate shape is what would break that assumption. +[workspace] + +[package] +name = "fastedge-fixture-rust-cdn" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = "0.2" diff --git a/test/fixtures/rust-app-cdn/src/lib.rs b/test/fixtures/rust-app-cdn/src/lib.rs new file mode 100644 index 0000000..811df22 --- /dev/null +++ b/test/fixtures/rust-app-cdn/src/lib.rs @@ -0,0 +1,34 @@ +use proxy_wasm::traits::*; +use proxy_wasm::types::*; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Trace); + proxy_wasm::set_root_context(|_| -> Box { Box::new(FixtureRoot) }); +}} + +struct FixtureRoot; + +impl Context for FixtureRoot {} + +impl RootContext for FixtureRoot { + fn create_http_context(&self, context_id: u32) -> Option> { + Some(Box::new(Fixture { context_id })) + } + + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } +} + +struct Fixture { + context_id: u32, +} + +impl Context for Fixture {} + +impl HttpContext for Fixture { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + self.set_http_request_header("x-fixture", Some(&self.context_id.to_string())); + Action::Continue + } +} diff --git a/test/fixtures/rust-app-wasi-http/Cargo.lock b/test/fixtures/rust-app-wasi-http/Cargo.lock new file mode 100644 index 0000000..f22b11d --- /dev/null +++ b/test/fixtures/rust-app-wasi-http/Cargo.lock @@ -0,0 +1,299 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "fastedge-fixture-rust-wasi-http" +version = "0.1.0" +dependencies = [ + "anyhow", + "wstd", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "wstd" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b52936db10a79bb724dadd1c2c3aac958e8229dcb1f1c7f2b7044ca9fc6a3a" +dependencies = [ + "anyhow", + "async-task", + "bytes", + "futures-lite", + "http", + "http-body", + "http-body-util", + "itoa", + "pin-project-lite", + "serde", + "serde_json", + "slab", + "wasip2", + "wstd-macro", +] + +[[package]] +name = "wstd-macro" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153db9b65508bf6c2efe26617169840c03671ba5719a35868db7682a5261f7d4" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/test/fixtures/rust-app-wasi-http/Cargo.toml b/test/fixtures/rust-app-wasi-http/Cargo.toml new file mode 100644 index 0000000..b4b80e9 --- /dev/null +++ b/test/fixtures/rust-app-wasi-http/Cargo.toml @@ -0,0 +1,19 @@ +# Mirrors FastEdge-sdk-rust/examples/http/wasi/hello_world. +# +# Deliberately has NO .cargo/config.toml: rustConfigWasiTarget must *infer* +# wasm32-wasip2 from the `wstd` dependency, and that inferred string becomes the +# --target argument. The wasip1 fixture covers the explicit-config branch; this +# one covers inference, which nothing tested before. +[workspace] + +[package] +name = "fastedge-fixture-rust-wasi-http" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wstd = "0.6" +anyhow = "1" diff --git a/test/fixtures/rust-app-wasi-http/src/lib.rs b/test/fixtures/rust-app-wasi-http/src/lib.rs new file mode 100644 index 0000000..2cd01de --- /dev/null +++ b/test/fixtures/rust-app-wasi-http/src/lib.rs @@ -0,0 +1,12 @@ +use wstd::http::body::Body; +use wstd::http::{Request, Response}; + +#[wstd::http_server] +async fn main(request: Request) -> anyhow::Result> { + let url = request.uri().to_string(); + + Ok(Response::builder() + .status(200) + .header("content-type", "text/plain;charset=UTF-8") + .body(Body::from(format!("fixture ok (wasi): {url}")))?) +} diff --git a/test/fixtures/rust-app/.cargo/config.toml b/test/fixtures/rust-app/.cargo/config.toml new file mode 100644 index 0000000..942a47a --- /dev/null +++ b/test/fixtures/rust-app/.cargo/config.toml @@ -0,0 +1,5 @@ +# Pins the fixture's wasm target, and exercises the code path that reads +# `[build] target` out of a workspace-controlled file โ€” the second injection +# vector in the CWE-78 report (see rustConfig.ts). +[build] +target = "wasm32-wasip1" diff --git a/test/fixtures/rust-app/Cargo.lock b/test/fixtures/rust-app/Cargo.lock new file mode 100644 index 0000000..fdb78d4 --- /dev/null +++ b/test/fixtures/rust-app/Cargo.lock @@ -0,0 +1,512 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastedge" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d665bf3e9be79d0be271c1fd4233c752d0c52f0e48c8e4e8cdb6da6ea6915a6a" +dependencies = [ + "bytes", + "fastedge-derive", + "http", + "mime", + "thiserror", + "wit-bindgen", +] + +[[package]] +name = "fastedge-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddaea791529e71281550aa22e30597e23362039f7027fe7f9db5d55a16c338ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "fastedge-fixture-rust-app" +version = "0.1.0" +dependencies = [ + "anyhow", + "fastedge", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +dependencies = [ + "bitflags", + "futures", + "once_cell", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/test/fixtures/rust-app/Cargo.toml b/test/fixtures/rust-app/Cargo.toml new file mode 100644 index 0000000..44f5b1c --- /dev/null +++ b/test/fixtures/rust-app/Cargo.toml @@ -0,0 +1,19 @@ +# Mirrors FastEdge-sdk-rust/examples/http/basic/hello_world. +# +# The empty [workspace] table keeps this fixture standalone. The `fastedge` +# dependency is the point: it brings the #[fastedge::http] proc macro, so the +# build exercises the real toolchain rather than a bare cdylib that would +# compile even if a real app could not. +[workspace] + +[package] +name = "fastedge-fixture-rust-app" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +fastedge = "0.4" +anyhow = "1" diff --git a/test/fixtures/rust-app/src/lib.rs b/test/fixtures/rust-app/src/lib.rs new file mode 100644 index 0000000..874778c --- /dev/null +++ b/test/fixtures/rust-app/src/lib.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use fastedge::body::Body; +use fastedge::http::{Request, Response, StatusCode}; + +#[fastedge::http] +fn main(req: Request) -> Result> { + let url = req.uri().to_string(); + + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/plain;charset=UTF-8") + .body(Body::from(format!("fixture ok: {url}"))) + .map_err(Into::into) +} diff --git a/test/integration/compilers.test.ts b/test/integration/compilers.test.ts new file mode 100644 index 0000000..e5aa160 --- /dev/null +++ b/test/integration/compilers.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execFileSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; + +import { compileJavascriptBinary } from "../../src/compiler/jsBuild"; +import { compileAssemblyScriptBinary } from "../../src/compiler/asBuild"; +import { compileRustAndFindBinary } from "../../src/compiler/rustBuild"; + +// --------------------------------------------------------------------------- +// These tests actually launch the build tools. The unit tests in +// src/compiler/compilerSpawn.test.ts mock spawn: they prove the arguments are +// safe, but prove nothing about whether the process starts. That gap matters +// most on Windows, where the build tools are launched as +// `process.execPath .js` precisely because .cmd shims cannot be spawned +// without a shell. +// +// One fixture per toolchain invocation the extension supports. Run on every OS +// in the CI matrix. They must never skip silently: a missing fixture +// dependency is a failure, not a pass. +// --------------------------------------------------------------------------- + +// vitest runs from the repo root. `import.meta.url` would not typecheck under +// this repo's commonjs tsconfig. +const FIXTURES = path.resolve(process.cwd(), "test", "fixtures"); + +const BUILD_TIMEOUT_MS = 600_000; +const WASM_MAGIC = [0x00, 0x61, 0x73, 0x6d]; +const noop = () => {}; + +// `fastedge-build` silently produces no output โ€” while printing "Build +// success!!" and exiting 0 โ€” when NODE_ENV=test, which is exactly what vitest +// sets. The child inherits our environment, so neutralise it here. This is an +// SDK bug, not a test artifact: any user with NODE_ENV=test in their shell hits +// it. Reported separately. +const originalNodeEnv = process.env.NODE_ENV; +beforeAll(() => { + process.env.NODE_ENV = "production"; +}); +afterAll(() => { + process.env.NODE_ENV = originalNodeEnv; +}); + +/** Fixtures build in place; keep the tree clean between runs. */ +function reset(appDir: string, ...alsoRemove: string[]) { + for (const target of [".fastedge-debug", ...alsoRemove]) { + fs.rmSync(path.join(appDir, target), { recursive: true, force: true }); + } + fs.mkdirSync(path.join(appDir, ".fastedge-debug"), { recursive: true }); +} + +function requireInstalled(appDir: string, packageName: string) { + const installed = path.join(appDir, "node_modules", ...packageName.split("/")); + if (!fs.existsSync(installed)) { + throw new Error( + `Fixture dependencies missing: ${installed}. Run "npm run fixtures:install" ` + + `(CI does this in the "Install fixture dependencies" step).`, + ); + } +} + +function expectWasm(wasmPath: string) { + expect(fs.existsSync(wasmPath)).toBe(true); + expect(fs.statSync(wasmPath).size).toBeGreaterThan(0); + // \0asm magic number โ€” a real module, not an empty file left by a tool that + // reported success without producing anything. + expect([...fs.readFileSync(wasmPath).subarray(0, 4)]).toEqual(WASM_MAGIC); +} + +describe("javascript HTTP app (real process)", () => { + const appDir = path.join(FIXTURES, "js-app"); + + beforeAll(() => { + requireInstalled(appDir, "@gcoredev/fastedge-sdk-js"); + reset(appDir); + }); + afterAll(() => reset(appDir)); + + it( + "compiles a wasm binary from the package.json entry point", + async () => { + const wasmPath = await compileJavascriptBinary( + path.join(appDir, "index.js"), + "workspace", + noop, + ); + expectWasm(wasmPath); + }, + BUILD_TIMEOUT_MS, + ); +}); + +describe("assemblyscript CDN app (real process)", () => { + const appDir = path.join(FIXTURES, "as-app"); + + beforeAll(() => { + requireInstalled(appDir, "assemblyscript"); + requireInstalled(appDir, "@gcoredev/proxy-wasm-sdk-as"); + reset(appDir, "build"); + }); + afterAll(() => reset(appDir, "build")); + + it( + "compiles a proxy-wasm binary with the asc compiler", + async () => { + const wasmPath = await compileAssemblyScriptBinary( + path.join(appDir, "assembly", "index.ts"), + noop, + ); + expectWasm(wasmPath); + }, + BUILD_TIMEOUT_MS, + ); +}); + +describe("rust HTTP app โ€” wasip1, explicit .cargo/config.toml", () => { + const appDir = path.join(FIXTURES, "rust-app"); + + beforeAll(() => { + // Fail loudly rather than skip: a silently absent toolchain would make this + // job green while testing nothing. + execFileSync("cargo", ["--version"], { stdio: "ignore" }); + reset(appDir, "target"); + }); + afterAll(() => reset(appDir, "target")); + + it( + "spawns cargo without a shell and finds the wasm artifact", + async () => { + const wasmPath = await compileRustAndFindBinary( + path.join(appDir, "src", "lib.rs"), + noop, + ); + expectWasm(wasmPath); + expect(fs.existsSync(path.join(appDir, "target", "wasm32-wasip1"))).toBe(true); + }, + BUILD_TIMEOUT_MS, + ); +}); + +describe("rust CDN proxy-wasm app โ€” wasip1", () => { + const appDir = path.join(FIXTURES, "rust-app-cdn"); + + beforeAll(() => { + execFileSync("cargo", ["--version"], { stdio: "ignore" }); + reset(appDir, "target"); + }); + afterAll(() => reset(appDir, "target")); + + it( + "selects the single wasm artifact from a proxy-wasm crate", + async () => { + // rustBuild only accepts a compiler-artifact message with exactly one + // filename; a different crate shape is what would break that. + const wasmPath = await compileRustAndFindBinary( + path.join(appDir, "src", "lib.rs"), + noop, + ); + expectWasm(wasmPath); + }, + BUILD_TIMEOUT_MS, + ); +}); + +describe("rust HTTP app โ€” wasip2, inferred from the wstd dependency", () => { + const appDir = path.join(FIXTURES, "rust-app-wasi-http"); + + beforeAll(() => { + execFileSync("cargo", ["--version"], { stdio: "ignore" }); + reset(appDir, "target"); + }); + afterAll(() => reset(appDir, "target")); + + it( + "builds against the inferred wasm32-wasip2 target", + async () => { + const wasmPath = await compileRustAndFindBinary( + path.join(appDir, "src", "lib.rs"), + noop, + ); + expectWasm(wasmPath); + // Proves the inferred target reached cargo, not just that something built. + expect(fs.existsSync(path.join(appDir, "target", "wasm32-wasip2"))).toBe(true); + }, + BUILD_TIMEOUT_MS, + ); +}); diff --git a/tsconfig.json b/tsconfig.json index 85f7974..9881501 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,12 +4,11 @@ "module": "commonjs", "moduleResolution": "node", "outDir": "./dist", - "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src"], - "exclude": ["node_modules", ".vscode", "dist"] + "include": ["src", "test/integration"], + "exclude": ["node_modules", ".vscode", "dist", "test/fixtures"] }