From 3133594bd239a09b5f9112a2436c840948f5e52f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 15:55:32 +0000 Subject: [PATCH 01/15] build: type against Node 22 and Bun 1.4, and check the tls options by hand @types/node goes from ^18 to ^22 and @types/bun from ^1.3.2 to ^1.4.2, which is what CI runs. Two things surfaced. The CONNECT handler's `head` is reassigned with bytes read off the socket, so it is annotated as a plain Buffer. And the Bun type package redeclares `connect` for 'node:tls' in a way that, against @types/node 22, takes and returns an untyped value, so the four call sites pin their options with `satisfies ConnectionOptions`, and a guard in parent-proxy.test.ts fails the typecheck if ConnectionOptions ever stops being a real type. Adds `npm run test:node`, which runs the two checks that start the library under node rather than Bun. The emitted JavaScript changes only by comment lines. --- package-lock.json | 54 ++++++-------------- package.json | 5 +- src/sandbox/http-proxy.ts | 7 ++- src/sandbox/parent-proxy.ts | 8 ++- test/sandbox/client-abort.test.ts | 16 ++++-- test/sandbox/credential-mask-sigv4.test.ts | 12 +++-- test/sandbox/parent-proxy.test.ts | 11 ++++ test/sandbox/tls-terminate-keepalive.test.ts | 16 ++++-- 8 files changed, 76 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9dece8387..1e5886275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,8 +19,8 @@ }, "devDependencies": { "@eslint/js": "^9.14.0", - "@types/bun": "^1.3.2", - "@types/node": "^18", + "@types/bun": "^1.4.2", + "@types/node": "^22", "@types/node-forge": "^1.3.14", "eslint": "^9.14.0", "eslint-config-prettier": "^8.10.0", @@ -382,13 +382,13 @@ } }, "node_modules/@types/bun": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.2.tgz", - "integrity": "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.4.2.tgz", + "integrity": "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.2" + "bun-types": "1.4.2" } }, "node_modules/@types/estree": { @@ -413,13 +413,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, "node_modules/@types/node-forge": { @@ -432,17 +432,6 @@ "@types/node": "*" } }, - "node_modules/@types/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", - "integrity": "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.0.2" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", @@ -1255,16 +1244,13 @@ } }, "node_modules/bun-types": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.2.tgz", - "integrity": "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.4.2.tgz", + "integrity": "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" - }, - "peerDependencies": { - "@types/react": "^19" } }, "node_modules/call-bind": { @@ -1435,14 +1421,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz", - "integrity": "sha512-D80T+tiqkd/8B0xNlbstWDG4x6aqVfO52+OlSUNIdkTvmNw0uQpJLeos2J/2XvpyidAFuTPmpad+tUxLndwj6g==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4698,9 +4676,9 @@ } }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, diff --git a/package.json b/package.json index c5491c3b2..35444db38 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "build:java-agent": "bun vendor/java-proxy-agent/build.ts", "clean": "rm -rf dist", "test": "bun test", + "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", @@ -33,8 +34,8 @@ }, "devDependencies": { "@eslint/js": "^9.14.0", - "@types/bun": "^1.3.2", - "@types/node": "^18", + "@types/bun": "^1.4.2", + "@types/node": "^22", "@types/node-forge": "^1.3.14", "eslint": "^9.14.0", "eslint-config-prettier": "^8.10.0", diff --git a/src/sandbox/http-proxy.ts b/src/sandbox/http-proxy.ts index f4ec8f410..6b019e37f 100644 --- a/src/sandbox/http-proxy.ts +++ b/src/sandbox/http-proxy.ts @@ -263,8 +263,11 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { } } - // Handle CONNECT requests for HTTPS traffic - server.on('connect', async (req, socket, head) => { + // Handle CONNECT requests for HTTPS traffic. `head` is typed as a plain + // Buffer because it is reassigned below with bytes read off the socket; + // the listener signature alone would narrow it to a Buffer over a + // non-shared ArrayBuffer, which those reads are not declared to return. + server.on('connect', async (req, socket, head: Buffer) => { // Attach error handler immediately to prevent unhandled errors socket.on('error', err => { logForDebugging(`Client socket error: ${err.message}`, { level: 'error' }) diff --git a/src/sandbox/parent-proxy.ts b/src/sandbox/parent-proxy.ts index e638e2456..1a06a167c 100644 --- a/src/sandbox/parent-proxy.ts +++ b/src/sandbox/parent-proxy.ts @@ -18,7 +18,7 @@ import type { LookupFunction, Socket } from 'node:net' import type { IncomingHttpHeaders } from 'node:http' import { BlockList, connect as netConnect, isIP } from 'node:net' -import { connect as tlsConnect } from 'node:tls' +import { connect as tlsConnect, type ConnectionOptions } from 'node:tls' import { URL } from 'node:url' import { logForDebugging } from '../utils/debug.js' import type { ParentProxyConfig } from './sandbox-config.js' @@ -334,6 +334,10 @@ export function connectViaParentProxy( destPort, authHeader: proxyAuthHeader(proxyUrl), readyEvent: useTls ? 'secureConnect' : 'connect', + // The TLS options are checked with `satisfies` because the call does not + // check them itself: the Bun type package redeclares `connect` for + // 'node:tls', and resolved against @types/node 22 that declaration takes + // and returns an untyped value. dial: () => useTls ? tlsConnect({ @@ -341,7 +345,7 @@ export function connectViaParentProxy( port: proxyPort, // SNI must be a hostname, never an IP literal (RFC 6066 §3). ...(isIP(proxyHost) ? {} : { servername: proxyHost }), - }) + } satisfies ConnectionOptions) : netConnect(proxyPort, proxyHost), }) } diff --git a/test/sandbox/client-abort.test.ts b/test/sandbox/client-abort.test.ts index 130ca7696..7b84aad92 100644 --- a/test/sandbox/client-abort.test.ts +++ b/test/sandbox/client-abort.test.ts @@ -4,7 +4,11 @@ import { connect } from 'node:net' import type { AddressInfo } from 'node:net' import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { connect as tlsConnect, type TLSSocket } from 'node:tls' +import { + connect as tlsConnect, + type ConnectionOptions, + type TLSSocket, +} from 'node:tls' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, disposeMitmCA } from '../../src/sandbox/mitm-ca.js' @@ -749,8 +753,14 @@ describe('client aborts through the TLS-terminating path', () => { }) raw.on('error', () => {}) raw.once('data', () => { - const tls = tlsConnect( - { socket: raw, ca: CA_PEM, servername: 'localhost' }, + // `connect` from 'node:tls' is untyped under the Bun type package + // with @types/node 22, so the options and the socket are typed here. + const tls: TLSSocket = tlsConnect( + { + socket: raw, + ca: CA_PEM, + servername: 'localhost', + } satisfies ConnectionOptions, () => fn(tls, () => resolve()), ) tls.on('error', () => resolve()) diff --git a/test/sandbox/credential-mask-sigv4.test.ts b/test/sandbox/credential-mask-sigv4.test.ts index bb8481e74..641db0a6d 100644 --- a/test/sandbox/credential-mask-sigv4.test.ts +++ b/test/sandbox/credential-mask-sigv4.test.ts @@ -18,7 +18,11 @@ import type { IncomingHttpHeaders, IncomingMessage } from 'node:http' import type { Server, AddressInfo } from 'node:net' import { spawn } from 'node:child_process' import { connect as netConnect } from 'node:net' -import { connect as tlsConnect } from 'node:tls' +import { + connect as tlsConnect, + type ConnectionOptions, + type TLSSocket, +} from 'node:tls' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -355,14 +359,16 @@ describe('SigV4 re-signing through the TLS-terminating proxy', () => { }) raw.on('error', () => {}) raw.once('data', () => { - const tls = tlsConnect( + // `connect` from 'node:tls' is untyped under the Bun type package + // with @types/node 22, so the options and the socket are typed here. + const tls: TLSSocket = tlsConnect( // SNI cannot carry an IP literal; the CA is pinned, so skip the // hostname identity check instead. { socket: raw, ca: CA_PEM, checkServerIdentity: () => undefined, - }, + } satisfies ConnectionOptions, () => { tls.write( `DELETE /bucket/key HTTP/1.1\r\n` + diff --git a/test/sandbox/parent-proxy.test.ts b/test/sandbox/parent-proxy.test.ts index b1b0585a9..b00d0dcae 100644 --- a/test/sandbox/parent-proxy.test.ts +++ b/test/sandbox/parent-proxy.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' import { connect } from 'node:net' +import type { ConnectionOptions } from 'node:tls' import { canonicalizeHost, isValidHost, @@ -12,6 +13,16 @@ import { stripHopByHop, } from '../../src/sandbox/parent-proxy.js' +// Checked by `tsc -p tsconfig.test.json`, not at run time. The TLS dial in +// parent-proxy.ts, and the tests that open a TLS connection themselves, pin +// their options with `satisfies ConnectionOptions`, because `connect` from +// 'node:tls' accepts any value under the Bun type package. That only checks +// anything while ConnectionOptions is itself a real type. If it ever resolves +// to an untyped value as well, the line below compiles, its directive goes +// unused, and the typecheck fails. +// @ts-expect-error -- 'servernme' is not an option, so this must not compile +void ({ servernme: 'proxy.example' } satisfies ConnectionOptions) + describe('parent-proxy: resolveParentProxy', () => { const saved: Record = {} const vars = [ diff --git a/test/sandbox/tls-terminate-keepalive.test.ts b/test/sandbox/tls-terminate-keepalive.test.ts index 4a6938e86..c99855927 100644 --- a/test/sandbox/tls-terminate-keepalive.test.ts +++ b/test/sandbox/tls-terminate-keepalive.test.ts @@ -2,7 +2,11 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Server, type Socket } from 'node:net' import type { LookupFunction } from 'node:net' -import { connect as tlsConnect, type TLSSocket } from 'node:tls' +import { + connect as tlsConnect, + type ConnectionOptions, + type TLSSocket, +} from 'node:tls' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' @@ -36,8 +40,14 @@ function openTunnel( }) raw.once('error', reject) raw.once('data', () => { - const tls = tlsConnect( - { socket: raw, ca: CA_PEM, servername: host }, + // `connect` from 'node:tls' is untyped under the Bun type package with + // @types/node 22, so the options and the socket are typed here. + const tls: TLSSocket = tlsConnect( + { + socket: raw, + ca: CA_PEM, + servername: host, + } satisfies ConnectionOptions, () => resolve(tls), ) tls.once('error', reject) From 98d1548572105458ec437473f4780d1d96a5d967 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 15:55:32 +0000 Subject: [PATCH 02/15] ci: pin ripgrep, Node and the actions, and enforce the lockfile Linux legs install ripgrep 14.1.1 from the upstream release by sha256 instead of taking whatever apt serves, and run the suites that exercise the scan a second time against 15.2.0: the two differ in exit status for an unreadable directory at --max-depth. The main legs move to Node 22 and run `npm run test:node` on Node 20, 22 and 24; publish moves from Node 18, below the package's floor, to 22. `npm install` becomes `npm ci`. Actions are pinned by commit with the version in a trailing comment. --- .github/workflows/integration-tests.yml | 147 +++++++++++++++++++++--- .github/workflows/release.yml | 67 +++++++---- 2 files changed, 176 insertions(+), 38 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 697000cc6..140e4789d 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -38,18 +38,19 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - # Node 20 (Active LTS): Node 18 has no official windows-arm64 - # build, so setup-node fails on the win/arm64 leg. engines is - # >=18.0.0 — this only pins the CI test version, not the floor. - node-version: '20' + # The release line the legs build and test on. engines in + # package.json is >=20.11.0: that is the floor, and this does not + # pin it. The steps at the end of the job that run the built + # package under Node repeat on 20 and 24 for that reason. + node-version: '22' - name: Setup Bun - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.2 @@ -58,7 +59,7 @@ jobs: # then drives it in a real JVM and self-skips when jar or JDK is absent. - name: Setup Java (macOS/Linux) if: matrix.os != 'windows' - uses: actions/setup-java@v4 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' @@ -158,7 +159,40 @@ jobs: if: matrix.os == 'linux' run: | sudo apt-get update - sudo apt-get install -y bubblewrap libseccomp-dev gcc socat ripgrep apparmor-profiles zsh + sudo apt-get install -y bubblewrap libseccomp-dev gcc socat apparmor-profiles zsh + + # The library reads both the output and the exit status of ripgrep, and + # those differ between ripgrep releases: for an unreadable directory + # that sits exactly at --max-depth, 14.1.1 prints "Permission denied" + # and exits 2, where 15.2.0 passes over it and exits 0. 14.1.1 is the + # release most installations have, so it is the one the main run uses, + # taken from the upstream release instead of whichever version the + # package archive serves that day. Each checksum is the one upstream + # publishes beside the archive, and a mismatch fails the step. + - name: Install ripgrep 14.1.1 (Linux) + if: matrix.os == 'linux' + run: | + case "${{ matrix.arch }}" in + x86-64) + asset=ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz + sha256=4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e + ;; + arm64) + asset=ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz + sha256=c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f + ;; + *) + echo "::error::no ripgrep archive is pinned for ${{ matrix.arch }}" + exit 1 + ;; + esac + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/$asset" + echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/ripgrep-14.1.1" + tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP/ripgrep-14.1.1" --strip-components=1 + # Ahead of everything already on PATH, for every later step. + echo "$RUNNER_TEMP/ripgrep-14.1.1" >> "$GITHUB_PATH" + "$RUNNER_TEMP/ripgrep-14.1.1/rg" --version - name: Enable unprivileged user namespaces (Linux) if: matrix.os == 'linux' @@ -182,9 +216,12 @@ jobs: run: | brew install ripgrep zsh + # `npm ci`, like the Windows legs above: it installs exactly what + # package-lock.json records and fails when package.json has moved away + # from it, where `npm install` would resolve again and carry on. - name: Install Node dependencies if: matrix.os != 'windows' - run: npm install + run: npm ci - name: Lint (ESLint) if: matrix.os == 'linux' && matrix.arch == 'x86-64' @@ -234,13 +271,95 @@ jobs: if: matrix.os == 'linux' run: PATH="$RUNNER_TEMP/bwrap-0.12/bin:$PATH" npm test - - name: Run Node.js fallback tests + # A release from the 15.x line, pinned like the one above. Over the same + # tree its scan can exit with a different status from 14.1.1 (see the + # install step above), so the suites that depend on the scan run against + # it as well. The macOS legs are not pinned to either: they run on + # whichever release Homebrew serves. + - name: Install ripgrep 15.2.0 (Linux) + if: matrix.os == 'linux' + run: | + case "${{ matrix.arch }}" in + x86-64) + asset=ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz + sha256=33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c + ;; + arm64) + asset=ripgrep-15.2.0-aarch64-unknown-linux-gnu.tar.gz + sha256=a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d + ;; + *) + echo "::error::no ripgrep archive is pinned for ${{ matrix.arch }}" + exit 1 + ;; + esac + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/$asset" + echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict + mkdir -p "$RUNNER_TEMP/ripgrep-15.2.0" + tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP/ripgrep-15.2.0" --strip-components=1 + "$RUNNER_TEMP/ripgrep-15.2.0/rg" --version + + # Every suite under test/ that names ripgrep or the mandatory-deny scan + # it implements, less the four that never start rg: config-validation + # (schema only), public-exports (the export list), check-dependencies + # (replaces the lookup with a spy) and macos-literal-deny-brackets + # (macOS only). + - name: Run the ripgrep-dependent tests against ripgrep 15.2.0 (Linux) + if: matrix.os == 'linux' + run: | + export PATH="$RUNNER_TEMP/ripgrep-15.2.0:$PATH" + rg --version + bun test \ + test/utils/ripgrep.test.ts \ + test/sandbox/mandatory-deny-paths.test.ts \ + test/sandbox/filesystem-disabled.test.ts \ + test/sandbox/linux-ancestor-pin.test.ts \ + test/sandbox/linux-bwrap-args-file.test.ts \ + test/sandbox/linux-mount-plan-record.test.ts \ + test/sandbox/read-deny-glob.test.ts \ + test/sandbox/readonly-deny-dir-binds.test.ts \ + test/sandbox/readonly-deny-dir-stubs.test.ts \ + test/sandbox/stale-mount-point.test.ts \ + test/sandbox/symlinked-deny-paths.test.ts + + # Everything above runs the library under Bun. `npm run test:node` is + # the part of the tree that runs the built package under Node: the + # whichSync fallback, and the control-fd suite, which starts dist/cli.js + # with the node on PATH. It runs on each release line from the engines + # floor up. Only these steps repeat, so the job matrix stays as it is. + - name: Run the tests that use Node (Node 22) if: matrix.os != 'windows' - run: node test/utils/which-node-test.mjs + run: | + node --version + npm run test:node + + - name: Setup Node.js 20 + if: matrix.os != 'windows' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '20' + + - name: Run the tests that use Node (Node 20) + if: matrix.os != 'windows' + run: | + node --version + npm run test:node + + - name: Setup Node.js 24 + if: matrix.os != 'windows' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Run the tests that use Node (Node 24) + if: matrix.os != 'windows' + run: | + node --version + npm run test:node - name: Upload test results if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: test-results-${{ matrix.os }}-${{ matrix.arch }} path: | @@ -263,7 +382,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Enable unprivileged user namespaces on host run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c183a9a9..c2022e46c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,13 +20,13 @@ jobs: - arch: arm64 runner: ubuntu-24.04-arm steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.2 - run: sudo apt-get update && sudo apt-get install -y gcc libseccomp-dev - run: npm run build:seccomp - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: seccomp-${{ matrix.arch }} path: vendor/seccomp/${{ matrix.arch }} @@ -36,16 +36,16 @@ jobs: name: Build JVM proxy agent runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.2 - - uses: actions/setup-java@v4 + - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 with: distribution: temurin java-version: '21' - run: npm run build:java-agent - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: java-proxy-agent path: vendor/java-proxy-agent/srt-proxy-agent.jar @@ -62,8 +62,8 @@ jobs: - arch: arm64 runner: windows-11-arm steps: - - uses: actions/checkout@v4 - - uses: oven-sh/setup-bun@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.4.2 # This job builds the helper that runs elevated on a user's machine, @@ -89,7 +89,7 @@ jobs: if (-not (Test-Path $exe)) { throw "$exe not found" } & $exe --version if ($LASTEXITCODE -ne 0) { throw "srt-win.exe --version exited $LASTEXITCODE" } - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: srt-win-${{ matrix.arch }} path: vendor/srt-win/${{ matrix.arch }} @@ -100,18 +100,27 @@ jobs: # prebuilt binary from inside an installed package. The arm64 # binary and seccomp binaries are not exercised here — the # publish job's tarball-contents check covers their presence. - name: Verify package (Windows) + # + # This is the one place the packed tarball is installed and imported, + # and it does so under Node, so it runs once on each Node release line + # from the engines floor in package.json (>=20.11.0) up. publish waits + # for all three. + name: Verify package (Windows / Node ${{ matrix.node }}) needs: build-srt-win runs-on: windows-latest + strategy: + fail-fast: false + matrix: + node: ['20', '22', '24'] env: # Husky's prepare hook chokes on Windows runners; skip it. HUSKY: '0' steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20' - - uses: actions/download-artifact@v4 + node-version: ${{ matrix.node }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: srt-win-x64 path: vendor/srt-win/x64 @@ -151,7 +160,7 @@ jobs: runs-on: ubuntu-latest environment: npm steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 @@ -172,36 +181,46 @@ jobs: exit 1 fi - - uses: actions/setup-node@v4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '18' + # The package is built and packed here, so this stays inside its + # own engines range (>=20.11.0). + node-version: '22' registry-url: 'https://registry.npmjs.org' - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: seccomp-x64 path: vendor/seccomp/x64 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: seccomp-arm64 path: vendor/seccomp/arm64 - run: chmod +x vendor/seccomp/*/apply-seccomp - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: java-proxy-agent path: vendor/java-proxy-agent - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: srt-win-x64 path: vendor/srt-win/x64 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: srt-win-arm64 path: vendor/srt-win/arm64 - - run: npm install + # `npm ci` installs exactly what package-lock.json records and fails + # when package.json has moved away from it, so what is built and + # published here is what CI tested. setup-node wrote an .npmrc that + # reads the registry token from NODE_AUTH_TOKEN, which only the publish + # step sets; left unset, npm sends the unexpanded placeholder as the + # token, and an empty value makes this install anonymous instead. + - run: npm ci + env: + NODE_AUTH_TOKEN: '' - name: Build, pack, verify contents # `npm pack` does NOT run prepublishOnly (only directory-mode From b7fc80a06d688367736ac6fe3a91b8d2fe4e478c Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 18:39:49 +0000 Subject: [PATCH 03/15] Raise the engines floor to Node 22 Node 20 is past its end of life, and nothing here is built or tested on it first: the legs build on 22. engines becomes >=22, the steps that ran the node-side checks on Node 20 go, and the package verification in release.yml covers 22 and 24. An install on Node 20 with engine-strict set is now refused, where it used to go through. --- .github/workflows/integration-tests.yml | 20 ++++---------------- .github/workflows/release.yml | 8 ++++---- package-lock.json | 2 +- package.json | 2 +- 4 files changed, 10 insertions(+), 22 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 140e4789d..488b32a48 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -43,10 +43,10 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - # The release line the legs build and test on. engines in - # package.json is >=20.11.0: that is the floor, and this does not - # pin it. The steps at the end of the job that run the built - # package under Node repeat on 20 and 24 for that reason. + # The release line the legs build and test on, and the engines + # floor in package.json (>=22). The steps at the end of the job + # that run the built package under Node repeat on 24, the line + # above it. node-version: '22' - name: Setup Bun @@ -333,18 +333,6 @@ jobs: node --version npm run test:node - - name: Setup Node.js 20 - if: matrix.os != 'windows' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: '20' - - - name: Run the tests that use Node (Node 20) - if: matrix.os != 'windows' - run: | - node --version - npm run test:node - - name: Setup Node.js 24 if: matrix.os != 'windows' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c2022e46c..26684ee6f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,15 +103,15 @@ jobs: # # This is the one place the packed tarball is installed and imported, # and it does so under Node, so it runs once on each Node release line - # from the engines floor in package.json (>=20.11.0) up. publish waits - # for all three. + # from the engines floor in package.json (>=22) up. publish waits for + # both. name: Verify package (Windows / Node ${{ matrix.node }}) needs: build-srt-win runs-on: windows-latest strategy: fail-fast: false matrix: - node: ['20', '22', '24'] + node: ['22', '24'] env: # Husky's prepare hook chokes on Windows runners; skip it. HUSKY: '0' @@ -184,7 +184,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: # The package is built and packed here, so this stays inside its - # own engines range (>=20.11.0). + # own engines range (>=22). node-version: '22' registry-url: 'https://registry.npmjs.org' diff --git a/package-lock.json b/package-lock.json index 1e5886275..669e7a49f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "typescript-eslint": "^8.13.0" }, "engines": { - "node": ">=20.11.0" + "node": ">=22" } }, "node_modules/@emnapi/core": { diff --git a/package.json b/package.json index 35444db38..61a81ef83 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "srt": "dist/cli.js" }, "engines": { - "node": ">=20.11.0" + "node": ">=22" }, "scripts": { "build": "tsc", From d6b977dfe2e98b1931ebe3cd7bcae7ebfa792739 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 18:42:54 +0000 Subject: [PATCH 04/15] test: run the built package bundled, minified and as bytecode The suites run the sources unbundled. An application that embeds the library ships it in one file, minified and compiled to bytecode, where a function's name, its source text and the files beside it on disk are all gone, and nothing here would notice code that leans on one of them. `npm run test:bundled` compiles a small program that imports dist/ that way and runs it: it initialises the manager, wraps real commands, and checks on the host what they wrote, that the network was closed, that the command's own exit status came back and that the mount point for a denied path is gone afterwards. It imports the built package, so it has a tsconfig of its own that is checked after the build rather than with the rest of test/. --- eslint.config.js | 11 +++ package.json | 1 + test/smoke/embedder.ts | 142 +++++++++++++++++++++++++++++++++++++++ test/smoke/tsconfig.json | 8 +++ tsconfig.test.json | 3 +- 5 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 test/smoke/embedder.ts create mode 100644 test/smoke/tsconfig.json diff --git a/eslint.config.js b/eslint.config.js index b7783d1c9..ec06d95f4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,6 +41,17 @@ export default [ }, }, }, + { + // Imports the BUILT package, so it is typechecked after the build and by + // a project of its own (see `npm run test:bundled`). + files: ['test/smoke/**/*.ts'], + languageOptions: { + parserOptions: { + project: './test/smoke/tsconfig.json', + projectService: false, + }, + }, + }, { plugins: { 'eslint-plugin-n': pluginNode, diff --git a/package.json b/package.json index 61a81ef83..5d0c1d657 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "clean": "rm -rf dist", "test": "bun test", "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts", + "test:bundled": "tsc --noEmit -p test/smoke/tsconfig.json && bun build --compile --minify --bytecode test/smoke/embedder.ts --outfile dist/bundled-smoke && ./dist/bundled-smoke", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts new file mode 100644 index 000000000..e2789247b --- /dev/null +++ b/test/smoke/embedder.ts @@ -0,0 +1,142 @@ +/** + * What an embedder ships, in miniature. `npm run test:bundled` compiles this + * program and the BUILT package it imports (dist/, not src/) into one minified + * file of bytecode, the way an application that embeds the library is built, + * and runs the result. The suites run the sources unbundled, so nothing else + * would notice code that leans on a function's name, on its own source text, + * or on a file that sits beside it on disk. + * + * It wraps real commands and checks what they did on the host. A compiled + * program has no `vendor/` beside it, so the seccomp helper is named to the + * library, as an embedder names its own copy: by SRT_SMOKE_APPLY_SECCOMP, or + * else the one built in the checkout this is run from. + */ +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SandboxManager } from '../../dist/index.js' + +function check(what: string, ok: boolean, detail = ''): void { + if (!ok) { + console.error(`FAILED: ${what}${detail === '' ? '' : `\n${detail}`}`) + process.exit(1) + } + console.log(`ok: ${what}`) +} + +// Bytecode is CommonJS, which has no top-level await. +async function main(): Promise { + // Named by the caller, or the one this checkout built for this machine. + const built = join( + process.cwd(), + 'vendor', + 'seccomp', + process.arch === 'arm64' ? 'arm64' : 'x64', + 'apply-seccomp', + ) + const applyPath = + process.env.SRT_SMOKE_APPLY_SECCOMP ?? + (existsSync(built) ? built : undefined) + const root = realpathSync(mkdtempSync(join(tmpdir(), 'srt-bundled-smoke-'))) + const work = join(root, 'work') + const outside = join(root, 'outside') + mkdirSync(work) + mkdirSync(outside) + + try { + await SandboxManager.initialize({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [work], + denyWrite: [join(work, 'denied.txt')], + }, + ...(applyPath === undefined ? {} : { seccomp: { applyPath } }), + }) + + // After initialize, so that the helper the configuration names is the + // one that is looked for. + const dependencies = SandboxManager.checkDependencies() + check( + 'the dependency check finds nothing missing', + dependencies.errors.length === 0, + dependencies.errors.join('\n'), + ) + + const wrapped = await SandboxManager.wrapWithSandbox( + [ + 'echo BOOTED', + `echo allowed > ${join(work, 'allowed.txt')}`, + `echo denied > ${join(work, 'denied.txt')} 2>/dev/null || echo WRITE-DENIED`, + `echo outside > ${join(outside, 'x.txt')} 2>/dev/null || echo OUTSIDE-DENIED`, + '(exec 3<>/dev/tcp/1.1.1.1/80) 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED', + 'exit 7', + ].join('; '), + ) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + cwd: work, + timeout: 60000, + }) + const output = `${result.stdout}${result.stderr}` + check('the wrapped command started', output.includes('BOOTED'), output) + check( + 'a write inside the allowed directory lands', + existsSync(join(work, 'allowed.txt')) && + readFileSync(join(work, 'allowed.txt'), 'utf8') === 'allowed\n', + output, + ) + check( + 'a write to a denied path inside it does not', + output.includes('WRITE-DENIED') && + (!existsSync(join(work, 'denied.txt')) || + readFileSync(join(work, 'denied.txt'), 'utf8') === ''), + output, + ) + check( + 'a write outside it does not', + output.includes('OUTSIDE-DENIED') && !existsSync(join(outside, 'x.txt')), + output, + ) + check( + 'the network is closed', + output.includes('NET-BLOCKED') && !output.includes('NET-OPEN'), + output, + ) + check( + "the command's own exit status comes back", + result.status === 7, + `status ${String(result.status)}\n${output}`, + ) + + SandboxManager.cleanupAfterCommand() + check( + 'the mount point for the denied path is gone after the command', + !existsSync(join(work, 'denied.txt')), + ) + + const config = SandboxManager.getConfig() + check( + 'the configuration reads back', + config?.filesystem.allowWrite.includes(work) === true, + ) + console.log('SMOKE OK') + } finally { + await SandboxManager.reset() + rmSync(root, { recursive: true, force: true }) + } +} + +main().catch((error: unknown) => { + console.error(error) + process.exit(1) +}) diff --git a/test/smoke/tsconfig.json b/test/smoke/tsconfig.json new file mode 100644 index 000000000..67a02a747 --- /dev/null +++ b/test/smoke/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.test.json", + "compilerOptions": { + "rootDir": "../.." + }, + "include": ["embedder.ts"], + "exclude": [] +} diff --git a/tsconfig.test.json b/tsconfig.test.json index 79c285891..0f6265533 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,5 +4,6 @@ "noEmit": true, "rootDir": "." }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"], + "exclude": ["test/smoke/**"] } From 540168506d6a62c7d65f6fd4d9cd4ce32cc10c6e Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 18:42:55 +0000 Subject: [PATCH 05/15] ci: a bundled-bytecode step, an Alpine container and a macOS 26 leg Three things CI did not see. The package as an embedder ships it, which `npm run test:bundled` now runs on every leg that can wrap. musl: the container job repeats its two runs in alpine:3.22, with the seccomp helper built static against musl's libseccomp and Alpine's own bubblewrap, socat and ripgrep. And the newest macOS beside the two images the macOS legs name, since what Seatbelt and the profile's regex engine accept has moved between releases. --- .github/workflows/integration-tests.yml | 48 +++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 488b32a48..a9dd49255 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -27,6 +27,12 @@ jobs: - arch: arm64 runner: macos-14 os: macos + # The newest macOS as well: the sandbox profile's own regex engine + # and what Seatbelt refuses have both moved between releases, and + # the two legs above stay on the images they name. + - arch: arm64-macos26 + runner: macos-26 + os: macos # Windows legs build srt-win, run the Rust smoke scripts, # then the TS integration suite (test/sandbox/winsrt.test.ts). - arch: x86-64 @@ -249,6 +255,15 @@ jobs: if: matrix.os != 'windows' run: npm test + # The suites run the sources unbundled. An application that embeds the + # library ships it minified and compiled to bytecode in one file, where + # a function's name, its source text and the files beside it are all + # gone. This compiles a small program that imports the built package + # that way and has it wrap real commands. + - name: Run the package bundled, minified and as bytecode + if: matrix.os != 'windows' + run: npm run test:bundled + # bubblewrap 0.12 refuses a mount on a symlink destination, which the # distribution's packaged bubblewrap accepts; run the suite against it # as well so a profile that mounts on a link is caught here, not on a @@ -410,3 +425,36 @@ jobs: echo "== as uid 0 without CAP_SYS_ADMIN" capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" ' + + # The same two runs on musl. The seccomp helper is static and built + # here against musl's libseccomp, and bubblewrap, socat and ripgrep are + # Alpine's own builds. bun picks its musl build by itself. The checkout + # is copied first: node_modules and the helper the step above left in + # the workspace were built for glibc. + - name: Run srt end-to-end as uid 0 in an Alpine container + run: | + docker run --rm \ + --cap-add SYS_ADMIN \ + --security-opt seccomp=unconfined \ + --security-opt apparmor=unconfined \ + -v "${{ github.workspace }}:/src:ro" \ + -e SRT_E2E_DOCKER=1 \ + alpine:3.22 \ + sh -euc ' + apk add --no-cache bash bubblewrap socat ripgrep python3 curl ca-certificates unzip gcc musl-dev linux-headers libseccomp-dev libseccomp-static libcap-utils libstdc++ libgcc nodejs npm tar + mkdir /work + tar -C /src --exclude=./node_modules --exclude=./dist --exclude=./vendor/seccomp/x64 --exclude=./vendor/seccomp/arm64 --exclude=./.git -cf - . | tar -C /work -xf - + cd /work + curl -fsSL https://bun.sh/install | bash -s bun-v1.4.2 + export PATH="$HOME/.bun/bin:$PATH" + bun --version + npm ci + npm run build:seccomp + npm run build + echo "== as uid 0 with CAP_SYS_ADMIN" + bun test test/docker-weak-sandbox.test.ts + echo "== as uid 0 without CAP_SYS_ADMIN" + capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" + echo "== bundled, minified and as bytecode" + npm run test:bundled + ' From 9abe353bcf7d9b829cd5bfe9c4f3aefdeab3ad8d Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 18:47:04 +0000 Subject: [PATCH 06/15] test: build the embedder both ways, and run it with no package around it An embedding application is built into code-split ESM chunks as often as into one flat file, and a split bundle evaluates modules in another order with the library in a chunk of its own. The runner now builds the program both ways, and the program reaches the library through import() so that it really does sit in a second chunk. Each executable is copied, with the seccomp helper, into an empty directory and run from there. An embedded copy has no vendor/, no node_modules and no package.json beside it, so a lookup that starts from the library's own location has to find nothing and not matter. On Linux the program also provokes a refusal, a profile with more deny paths than bubblewrap takes arguments for, and checks that the error is still an instance of the exported class and still carries the name and the code a caller branches on, with every identifier shortened. Inside a container the program asks for the weaker nested sandbox, as the container suite does: bubblewrap cannot mount /proc there. --- package.json | 2 +- test/smoke/embedder.ts | 71 +++++++++++++++++++++------ test/smoke/run-bundled.ts | 100 ++++++++++++++++++++++++++++++++++++++ test/smoke/tsconfig.json | 2 +- 4 files changed, 158 insertions(+), 17 deletions(-) create mode 100644 test/smoke/run-bundled.ts diff --git a/package.json b/package.json index 5d0c1d657..555708442 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "clean": "rm -rf dist", "test": "bun test", "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts", - "test:bundled": "tsc --noEmit -p test/smoke/tsconfig.json && bun build --compile --minify --bytecode test/smoke/embedder.ts --outfile dist/bundled-smoke && ./dist/bundled-smoke", + "test:bundled": "tsc --noEmit -p test/smoke/tsconfig.json && bun test/smoke/run-bundled.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts index e2789247b..d68f1dba7 100644 --- a/test/smoke/embedder.ts +++ b/test/smoke/embedder.ts @@ -6,10 +6,16 @@ * would notice code that leans on a function's name, on its own source text, * or on a file that sits beside it on disk. * - * It wraps real commands and checks what they did on the host. A compiled - * program has no `vendor/` beside it, so the seccomp helper is named to the - * library, as an embedder names its own copy: by SRT_SMOKE_APPLY_SECCOMP, or - * else the one built in the checkout this is run from. + * It wraps real commands and checks what they did on the host, and that a + * refusal still carries the name and the code a caller branches on once every + * identifier has been shortened. The library is reached through `import()`, so + * that where the bundle is split into chunks it sits in one of its own. + * + * A compiled program has no package directory around it: no `vendor/`, no + * `node_modules`, no `package.json`, and `import.meta.url` names nothing on + * disk. The runner (run-bundled.ts) runs it from a directory of its own for + * that reason, and names the seccomp helper to it in SRT_SMOKE_APPLY_SECCOMP, + * as an embedder names its own copy to the library. */ import { spawnSync } from 'node:child_process' import { @@ -22,7 +28,6 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { SandboxManager } from '../../dist/index.js' function check(what: string, ok: boolean, detail = ''): void { if (!ok) { @@ -34,17 +39,17 @@ function check(what: string, ok: boolean, detail = ''): void { // Bytecode is CommonJS, which has no top-level await. async function main(): Promise { - // Named by the caller, or the one this checkout built for this machine. - const built = join( - process.cwd(), - 'vendor', - 'seccomp', - process.arch === 'arm64' ? 'arm64' : 'x64', - 'apply-seccomp', + const { SandboxManager, LinuxSandboxProfileError } = await import( + '../../dist/index.js' ) - const applyPath = - process.env.SRT_SMOKE_APPLY_SECCOMP ?? - (existsSync(built) ? built : undefined) + const applyPath = process.env.SRT_SMOKE_APPLY_SECCOMP + // Inside a container the host masks /proc, and a sandbox nested in one + // has to be the weaker kind the library has a setting for; the container + // job says so the way it says it to the suite it runs. + const nested = + process.env.SRT_E2E_DOCKER === '1' + ? { enableWeakerNestedSandbox: true } + : {} const root = realpathSync(mkdtempSync(join(tmpdir(), 'srt-bundled-smoke-'))) const work = join(root, 'work') const outside = join(root, 'outside') @@ -60,6 +65,7 @@ async function main(): Promise { denyWrite: [join(work, 'denied.txt')], }, ...(applyPath === undefined ? {} : { seccomp: { applyPath } }), + ...nested, }) // After initialize, so that the helper the configuration names is the @@ -129,6 +135,41 @@ async function main(): Promise { 'the configuration reads back', config?.filesystem.allowWrite.includes(work) === true, ) + + if (process.platform === 'linux') { + // More absent deny paths than bubblewrap takes arguments for: the wrap + // is refused before anything is spawned or written. + SandboxManager.updateConfig({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [work], + denyWrite: Array.from({ length: 4000 }, (_, i) => + join(work, `absent-${i}`), + ), + }, + ...(applyPath === undefined ? {} : { seccomp: { applyPath } }), + ...nested, + }) + const refusal: unknown = await SandboxManager.wrapWithSandbox( + 'true', + ).then( + () => undefined, + (error: unknown) => error, + ) + check( + 'a profile that does not fit is refused with a typed error', + refusal instanceof LinuxSandboxProfileError, + String(refusal), + ) + const typed = refusal as InstanceType + check( + 'which keeps its name and its code with every identifier shortened', + typed.name === 'LinuxSandboxProfileError' && + typed.code === 'too_many_arguments', + `name ${typed.name}, code ${String(typed.code)}`, + ) + } console.log('SMOKE OK') } finally { await SandboxManager.reset() diff --git a/test/smoke/run-bundled.ts b/test/smoke/run-bundled.ts new file mode 100644 index 000000000..27f6fcb8c --- /dev/null +++ b/test/smoke/run-bundled.ts @@ -0,0 +1,100 @@ +/** + * Builds test/smoke/embedder.ts the ways an embedding application is built and + * runs each result: `npm run test:bundled`, after `npm run build`. + * + * Two forms, both minified with identifiers shortened and compiled to bytecode + * in one executable: one flat file, and code-split ESM chunks, where modules + * are evaluated in another order and the library sits in a chunk of its own. + * + * Each executable is copied, with the seccomp helper this checkout built, into + * an empty directory and run from there. Nothing of the package is on disk + * around it then, which is how an embedded copy runs: a lookup that starts + * from the library's own location finds nothing, and has to be one the + * configuration already answered. + */ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + copyFileSync, + existsSync, + mkdtempSync, + realpathSync, + rmSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const repo = process.cwd() +const entry = join(repo, 'test', 'smoke', 'embedder.ts') +if (!existsSync(join(repo, 'dist', 'index.js'))) { + console.error('dist/ is not built: run `npm run build` first') + process.exit(1) +} + +const FORMS: Array<{ name: string; flags: string[] }> = [ + { name: 'one file', flags: [] }, + { name: 'split ESM chunks', flags: ['--splitting', '--format=esm'] }, +] + +const builtHelper = join( + repo, + 'vendor', + 'seccomp', + process.arch === 'arm64' ? 'arm64' : 'x64', + 'apply-seccomp', +) + +let failed = false +for (const form of FORMS) { + const home = realpathSync(mkdtempSync(join(tmpdir(), 'srt-embedder-'))) + try { + const program = join(home, 'embedder') + const build = spawnSync( + process.execPath, + [ + 'build', + '--compile', + '--minify', + '--bytecode', + ...form.flags, + entry, + '--outfile', + program, + ], + { cwd: repo, encoding: 'utf8' }, + ) + if (build.status !== 0) { + console.error( + `== ${form.name}: did not build\n${build.stdout}${build.stderr}`, + ) + failed = true + continue + } + + const env: Record = { ...process.env } + delete env.SRT_SMOKE_APPLY_SECCOMP + if (process.platform === 'linux' && existsSync(builtHelper)) { + const helper = join(home, 'apply-seccomp') + copyFileSync(builtHelper, helper) + chmodSync(helper, 0o755) + env.SRT_SMOKE_APPLY_SECCOMP = helper + } + + console.log(`== ${form.name}`) + const run = spawnSync(program, [], { + cwd: home, + env, + encoding: 'utf8', + timeout: 300000, + }) + process.stdout.write(run.stdout) + process.stderr.write(run.stderr) + if (run.status !== 0 || !run.stdout.includes('SMOKE OK')) { + console.error(`== ${form.name}: FAILED (status ${String(run.status)})`) + failed = true + } + } finally { + rmSync(home, { recursive: true, force: true }) + } +} +process.exit(failed ? 1 : 0) diff --git a/test/smoke/tsconfig.json b/test/smoke/tsconfig.json index 67a02a747..c9f18848c 100644 --- a/test/smoke/tsconfig.json +++ b/test/smoke/tsconfig.json @@ -3,6 +3,6 @@ "compilerOptions": { "rootDir": "../.." }, - "include": ["embedder.ts"], + "include": ["embedder.ts", "run-bundled.ts"], "exclude": [] } From 87b575618c56d1bd99d354fbda2fed8a96a0f89f Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 18:47:04 +0000 Subject: [PATCH 07/15] ci: install bun in the containers from its release archive, by checksum Both container steps piped bun's installer into a shell. They now fetch the 1.4.2 release archive for the architecture, glibc or musl, check it against a sha256 recorded here and install the one binary, the way the ripgrep steps do. --- .github/workflows/integration-tests.yml | 37 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a9dd49255..297315f6f 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -409,14 +409,24 @@ jobs: ubuntu:24.04 \ bash -euo pipefail -c ' apt-get update -qq - # node and npm come from the image instead of a second piped - # installer, and bun is pinned to the version the rest of the - # matrix runs (oven-sh/setup-bun above). The tree carries a - # dozen bun-version-specific workarounds, so "whatever bun - # shipped this morning" is not the runtime under test. + # node and npm come from the image, and bun is pinned to the + # version the rest of the matrix runs (oven-sh/setup-bun above). + # The tree carries a dozen bun-version-specific workarounds, so + # "whatever bun shipped this morning" is not the runtime under + # test. apt-get install -y -qq bubblewrap socat ripgrep python3 curl ca-certificates unzip gcc libseccomp-dev libcap2-bin nodejs npm - curl -fsSL https://bun.sh/install | bash -s bun-v1.4.2 - export PATH="$HOME/.bun/bin:$PATH" + # The release archive, by checksum, rather than an installer + # piped into a shell. + case "$(uname -m)" in + x86_64) bun_zip=bun-linux-x64; bun_sha=36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;; + aarch64) bun_zip=bun-linux-aarch64; bun_sha=54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;; + *) echo "no bun archive is pinned for $(uname -m)"; exit 1 ;; + esac + curl -fsSL -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/${bun_zip}.zip" + echo "${bun_sha} /tmp/bun.zip" | sha256sum -c - + unzip -q /tmp/bun.zip -d /tmp + install -m 0755 "/tmp/${bun_zip}/bun" /usr/local/bin/bun + bun --version npm ci npm run build:seccomp npm run build @@ -428,7 +438,7 @@ jobs: # The same two runs on musl. The seccomp helper is static and built # here against musl's libseccomp, and bubblewrap, socat and ripgrep are - # Alpine's own builds. bun picks its musl build by itself. The checkout + # Alpine's own builds, and bun is its musl build. The checkout # is copied first: node_modules and the helper the step above left in # the workspace were built for glibc. - name: Run srt end-to-end as uid 0 in an Alpine container @@ -445,8 +455,15 @@ jobs: mkdir /work tar -C /src --exclude=./node_modules --exclude=./dist --exclude=./vendor/seccomp/x64 --exclude=./vendor/seccomp/arm64 --exclude=./.git -cf - . | tar -C /work -xf - cd /work - curl -fsSL https://bun.sh/install | bash -s bun-v1.4.2 - export PATH="$HOME/.bun/bin:$PATH" + case "$(uname -m)" in + x86_64) bun_zip=bun-linux-x64-musl; bun_sha=4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742 ;; + aarch64) bun_zip=bun-linux-aarch64-musl; bun_sha=71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df ;; + *) echo "no bun archive is pinned for $(uname -m)"; exit 1 ;; + esac + curl -fsSL -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/${bun_zip}.zip" + echo "${bun_sha} /tmp/bun.zip" | sha256sum -c - + unzip -q /tmp/bun.zip -d /tmp + install -m 0755 "/tmp/${bun_zip}/bun" /usr/local/bin/bun bun --version npm ci npm run build:seccomp From 093576eaf929d4985bb5103322bce89e4f8e5e12 Mon Sep 17 00:00:00 2001 From: Ron Leizrowice Date: Mon, 21 Sep 2026 20:02:30 +0000 Subject: [PATCH 08/15] test: reach the helper and ripgrep by running the embedder under their names An application that has the seccomp helper and ripgrep compiled into its own executable reaches them by running itself under another name: `seccomp.argv0` with `applyPath: '/proc/self/fd/3'`, fd 3 of every wrapped command being an open handle on the executable, and `ripgrep.argv0` with the executable as the command. No suite runs that arrangement, and it is a path of its own through the wrap: the ARGV0 assignment ahead of the helper, a helper path that is used as given and never looked for, and no helper file anywhere to find. The smoke program now plays such an executable. Started as `apply-seccomp` or as `rg` it runs the real one; and on Linux the runner runs each build a second time configured that way, checking that the scan did start it as `rg`, that the wrapped command did reach the helper through fd 3, and that it ran under the seccomp filter. --- test/smoke/embedder.ts | 105 ++++++++++++++++++++++++++++++++++++-- test/smoke/run-bundled.ts | 41 ++++++++++----- 2 files changed, 130 insertions(+), 16 deletions(-) diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts index d68f1dba7..b68ee0945 100644 --- a/test/smoke/embedder.ts +++ b/test/smoke/embedder.ts @@ -16,18 +16,70 @@ * disk. The runner (run-bundled.ts) runs it from a directory of its own for * that reason, and names the seccomp helper to it in SRT_SMOKE_APPLY_SECCOMP, * as an embedder names its own copy to the library. + * + * With SRT_SMOKE_MULTICALL=1 it goes one step further, to the arrangement of + * an embedder that has the helper and ripgrep INSIDE its own executable and + * reaches them by running itself under another name: `seccomp.argv0` with + * `applyPath: '/proc/self/fd/3'`, fd 3 being an open handle on the executable + * that every wrapped command inherits, and `ripgrep.argv0` with the + * executable as the command. This program plays that executable: started as + * `apply-seccomp` or as `rg`, it runs the real one (see runAsAnotherProgram). + * No file path of a helper is given to the library then, and none may be + * looked for. */ import { spawnSync } from 'node:child_process' import { + appendFileSync, + closeSync, existsSync, mkdirSync, mkdtempSync, + openSync, readFileSync, realpathSync, rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { basename, join } from 'node:path' + +/** + * What a multicall executable does first: if it was started under the name of + * a program it carries, be that program. The library names the helper through + * the ARGV0 variable, since nothing between it and the exec inside the sandbox + * can set argv[0], and ripgrep through argv[0] itself. + */ +function runAsAnotherProgram(): void { + const name = process.env.ARGV0 ?? basename(process.argv0) + const real = + name === 'apply-seccomp' + ? process.env.SRT_SMOKE_APPLY_SECCOMP + : name === 'rg' + ? process.env.SRT_SMOKE_RG + : undefined + if (name !== 'apply-seccomp' && name !== 'rg') return + if (real === undefined) { + console.error(`started as ${name}, and no ${name} to run was named`) + process.exit(127) + } + const log = process.env.SRT_SMOKE_MULTICALL_LOG + if (log !== undefined) { + try { + appendFileSync(log, `${name}\n`) + } catch { + // Not writable from where this was started: the run is what matters. + } + } + // The name is this process's, not what it runs. + const env = { ...process.env } + delete env.ARGV0 + const ran = spawnSync(real, process.argv.slice(2), { stdio: 'inherit', env }) + if (ran.error !== undefined) { + console.error(`${name}: ${String(ran.error)}`) + process.exit(127) + } + process.exit(ran.status ?? 128) +} +runAsAnotherProgram() function check(what: string, ok: boolean, detail = ''): void { if (!ok) { @@ -42,6 +94,7 @@ async function main(): Promise { const { SandboxManager, LinuxSandboxProfileError } = await import( '../../dist/index.js' ) + const multicall = process.env.SRT_SMOKE_MULTICALL === '1' const applyPath = process.env.SRT_SMOKE_APPLY_SECCOMP // Inside a container the host masks /proc, and a sandbox nested in one // has to be the weaker kind the library has a setting for; the container @@ -56,6 +109,23 @@ async function main(): Promise { mkdirSync(work) mkdirSync(outside) + // How the library is told to reach the helper and ripgrep. Carried inside + // this executable: fd 3 of every wrapped command is a handle on it, and the + // shim above writes a line where a wrapped command can write, each time it + // is started as one of them. + const multicallLog = join(work, 'multicall.log') + const self = multicall ? openSync(process.execPath, 'r') : undefined + if (multicall) process.env.SRT_SMOKE_MULTICALL_LOG = multicallLog + const tools = multicall + ? { + seccomp: { applyPath: '/proc/self/fd/3', argv0: 'apply-seccomp' }, + ripgrep: { command: process.execPath, args: [], argv0: 'rg' }, + } + : applyPath === undefined + ? {} + : { seccomp: { applyPath } } + const helperExpected = process.platform === 'linux' && 'seccomp' in tools + try { await SandboxManager.initialize({ network: { allowedDomains: [], deniedDomains: [] }, @@ -64,7 +134,7 @@ async function main(): Promise { allowWrite: [work], denyWrite: [join(work, 'denied.txt')], }, - ...(applyPath === undefined ? {} : { seccomp: { applyPath } }), + ...tools, ...nested, }) @@ -83,7 +153,8 @@ async function main(): Promise { `echo allowed > ${join(work, 'allowed.txt')}`, `echo denied > ${join(work, 'denied.txt')} 2>/dev/null || echo WRITE-DENIED`, `echo outside > ${join(outside, 'x.txt')} 2>/dev/null || echo OUTSIDE-DENIED`, - '(exec 3<>/dev/tcp/1.1.1.1/80) 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED', + '(exec 9<>/dev/tcp/1.1.1.1/80) 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED', + 'grep -q "^Seccomp:[[:space:]]*2" /proc/self/status 2>/dev/null && echo SECCOMP-ON || echo SECCOMP-OFF', 'exit 7', ].join('; '), ) @@ -92,6 +163,9 @@ async function main(): Promise { encoding: 'utf8', cwd: work, timeout: 60000, + // fd 3 of the command is this executable, where that is how the helper + // is reached. + stdio: self === undefined ? 'pipe' : ['ignore', 'pipe', 'pipe', self], }) const output = `${result.stdout}${result.stderr}` check('the wrapped command started', output.includes('BOOTED'), output) @@ -118,6 +192,28 @@ async function main(): Promise { output.includes('NET-BLOCKED') && !output.includes('NET-OPEN'), output, ) + if (helperExpected) { + check( + 'the command ran under the seccomp filter', + output.includes('SECCOMP-ON'), + output, + ) + } + if (multicall) { + const started = existsSync(multicallLog) + ? readFileSync(multicallLog, 'utf8').split('\n') + : [] + check( + 'the scan ran ripgrep by starting this executable as rg', + started.includes('rg'), + started.join(','), + ) + check( + 'the command reached the helper by starting this executable as apply-seccomp, through fd 3', + started.includes('apply-seccomp'), + started.join(','), + ) + } check( "the command's own exit status comes back", result.status === 7, @@ -148,7 +244,7 @@ async function main(): Promise { join(work, `absent-${i}`), ), }, - ...(applyPath === undefined ? {} : { seccomp: { applyPath } }), + ...tools, ...nested, }) const refusal: unknown = await SandboxManager.wrapWithSandbox( @@ -172,6 +268,7 @@ async function main(): Promise { } console.log('SMOKE OK') } finally { + if (self !== undefined) closeSync(self) await SandboxManager.reset() rmSync(root, { recursive: true, force: true }) } diff --git a/test/smoke/run-bundled.ts b/test/smoke/run-bundled.ts index 27f6fcb8c..855261dca 100644 --- a/test/smoke/run-bundled.ts +++ b/test/smoke/run-bundled.ts @@ -11,6 +11,11 @@ * around it then, which is how an embedded copy runs: a lookup that starts * from the library's own location finds nothing, and has to be one the * configuration already answered. + * + * On Linux each is run twice: told where the helper file is, and as a + * multicall executable that carries the helper and ripgrep itself and is + * started under their names (see embedder.ts), which is how an application + * that has both compiled in reaches them. */ import { spawnSync } from 'node:child_process' import { @@ -80,18 +85,30 @@ for (const form of FORMS) { env.SRT_SMOKE_APPLY_SECCOMP = helper } - console.log(`== ${form.name}`) - const run = spawnSync(program, [], { - cwd: home, - env, - encoding: 'utf8', - timeout: 300000, - }) - process.stdout.write(run.stdout) - process.stderr.write(run.stderr) - if (run.status !== 0 || !run.stdout.includes('SMOKE OK')) { - console.error(`== ${form.name}: FAILED (status ${String(run.status)})`) - failed = true + const modes: Array<{ name: string; env: typeof env }> = [ + { name: form.name, env }, + ] + const ripgrep = Bun.which('rg') + if (env.SRT_SMOKE_APPLY_SECCOMP !== undefined && ripgrep !== null) { + modes.push({ + name: `${form.name}, helper and ripgrep reached through argv0`, + env: { ...env, SRT_SMOKE_MULTICALL: '1', SRT_SMOKE_RG: ripgrep }, + }) + } + for (const mode of modes) { + console.log(`== ${mode.name}`) + const run = spawnSync(program, [], { + cwd: home, + env: mode.env, + encoding: 'utf8', + timeout: 300000, + }) + process.stdout.write(run.stdout) + process.stderr.write(run.stderr) + if (run.status !== 0 || !run.stdout.includes('SMOKE OK')) { + console.error(`== ${mode.name}: FAILED (status ${String(run.status)})`) + failed = true + } } } finally { rmSync(home, { recursive: true, force: true }) From 54716d507014844b33a5519a2c10ccb10922e43c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:51:05 +0000 Subject: [PATCH 09/15] package.json: test:bundled after typecheck, which it starts with Apart from test:node, which the toolchain pins add: two lines added side by side would conflict once those are squashed onto main. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c92ab7083..79c9e10c7 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ "clean": "rm -rf dist", "test": "bun test", "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts", - "test:bundled": "tsc --noEmit -p test/smoke/tsconfig.json && bun test/smoke/run-bundled.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", + "test:bundled": "tsc --noEmit -p test/smoke/tsconfig.json && bun test/smoke/run-bundled.ts", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", "format": "prettier --write 'src/**/*.ts' --cache --log-level warn", From cf6cfdd840debd3730a2281bc053d407a39c6cd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:04:59 +0000 Subject: [PATCH 10/15] ci: what a review of the whole change found - A failed write to an inherited fd has a test. Under Bun a chunk too large to buffer makes write() itself reject, which main leaves floating and reports to the stream as a success. The three ways a write fails now go through one async function. - tls.connect() is typed once, in test/helpers/tls-connect.ts, in place of an annotation at each callback. All eighteen suites that dial TLS use it, so their options and sockets are checked again. The guard on ConnectionOptions moves there. - The two ripgrep installs share .github/scripts/install-ripgrep.sh. - `bun test` passes over an argument that matches no file, so the step that reruns thirteen suites on ripgrep 15.2.0 first checks that each exists. - test:node also runs the four other suites that start `node` themselves (real-path, default-write-paths, rules-through-links-on-every-runtime, tls-terminate-tunnel-ends), which so far only saw Node 22. - The Node 22 step goes back to what it is on main: `npm test` has already run the rest with Node 22 on PATH. The comment no longer promises every release line. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- .github/scripts/install-ripgrep.sh | 25 +++++ .github/workflows/integration-tests.yml | 96 +++++++------------ package.json | 2 +- src/sandbox/proxy-only.ts | 15 ++- test/helpers/tls-connect.ts | 17 ++++ test/proxy-cli.test.ts | 4 +- test/sandbox/client-abort.test.ts | 17 +--- test/sandbox/client-hello-sni.test.ts | 2 +- test/sandbox/credential-mask-sigv4.test.ts | 12 +-- test/sandbox/deny-late-body.test.ts | 4 +- .../sandbox/invalid-upstream-response.test.ts | 6 +- test/sandbox/parent-proxy.test.ts | 11 --- test/sandbox/proxy-only-edges.test.ts | 25 ++++- test/sandbox/proxy-only.test.ts | 19 ++-- test/sandbox/request-rewriting.test.ts | 4 +- .../sandbox/request-target-forwarding.test.ts | 6 +- test/sandbox/response-header-bytes.test.ts | 7 +- .../tls-terminate-destroy-soon.test.ts | 5 +- .../tls-terminate-handshake-deadline.test.ts | 3 +- test/sandbox/tls-terminate-in-process.test.ts | 14 ++- test/sandbox/tls-terminate-keepalive.test.ts | 17 +--- .../tls-terminate-relay-budget.test.ts | 2 +- .../sandbox/tls-terminate-upload-gate.test.ts | 7 +- test/sandbox/trailers-then-request.test.ts | 3 +- 24 files changed, 153 insertions(+), 170 deletions(-) create mode 100755 .github/scripts/install-ripgrep.sh create mode 100644 test/helpers/tls-connect.ts diff --git a/.github/scripts/install-ripgrep.sh b/.github/scripts/install-ripgrep.sh new file mode 100755 index 000000000..c9957e8b9 --- /dev/null +++ b/.github/scripts/install-ripgrep.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# install-ripgrep.sh +# +# Unpacks that ripgrep release into $RUNNER_TEMP/ripgrep-, from the +# upstream release instead of whichever version the package archive serves +# that day. Each checksum is the one upstream publishes beside the archive, +# and a mismatch fails the step. +set -euo pipefail + +version=$1 +case "$(uname -m)" in + x86_64) asset=ripgrep-$version-x86_64-unknown-linux-musl.tar.gz sha256=$2 ;; + aarch64) asset=ripgrep-$version-aarch64-unknown-linux-gnu.tar.gz sha256=$3 ;; + *) + echo "::error::no ripgrep archive is pinned for $(uname -m)" + exit 1 + ;; +esac +into=$RUNNER_TEMP/ripgrep-$version + +curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/$version/$asset" +echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict +mkdir -p "$into" +tar -xzf "$RUNNER_TEMP/$asset" -C "$into" --strip-components=1 +"$into/rg" --version diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index ab200642a..7155860dd 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -185,34 +185,15 @@ jobs: # those differ between ripgrep releases: for an unreadable directory # that sits exactly at --max-depth, 14.1.1 prints "Permission denied" # and exits 2, where 15.2.0 passes over it and exits 0. 14.1.1 is the - # release most installations have, so it is the one the main run uses, - # taken from the upstream release instead of whichever version the - # package archive serves that day. Each checksum is the one upstream - # publishes beside the archive, and a mismatch fails the step. + # release most installations have, so it is the one the main run uses. - name: Install ripgrep 14.1.1 (Linux) if: matrix.os == 'linux' run: | - case "${{ matrix.arch }}" in - x86-64) - asset=ripgrep-14.1.1-x86_64-unknown-linux-musl.tar.gz - sha256=4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e - ;; - arm64) - asset=ripgrep-14.1.1-aarch64-unknown-linux-gnu.tar.gz - sha256=c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f - ;; - *) - echo "::error::no ripgrep archive is pinned for ${{ matrix.arch }}" - exit 1 - ;; - esac - curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/14.1.1/$asset" - echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict - mkdir -p "$RUNNER_TEMP/ripgrep-14.1.1" - tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP/ripgrep-14.1.1" --strip-components=1 + .github/scripts/install-ripgrep.sh 14.1.1 \ + 4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e \ + c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f # Ahead of everything already on PATH, for every later step. echo "$RUNNER_TEMP/ripgrep-14.1.1" >> "$GITHUB_PATH" - "$RUNNER_TEMP/ripgrep-14.1.1/rg" --version - name: Enable unprivileged user namespaces (Linux) if: matrix.os == 'linux' @@ -336,25 +317,9 @@ jobs: - name: Install ripgrep 15.2.0 (Linux) if: matrix.os == 'linux' run: | - case "${{ matrix.arch }}" in - x86-64) - asset=ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz - sha256=33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c - ;; - arm64) - asset=ripgrep-15.2.0-aarch64-unknown-linux-gnu.tar.gz - sha256=a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d - ;; - *) - echo "::error::no ripgrep archive is pinned for ${{ matrix.arch }}" - exit 1 - ;; - esac - curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/15.2.0/$asset" - echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict - mkdir -p "$RUNNER_TEMP/ripgrep-15.2.0" - tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP/ripgrep-15.2.0" --strip-components=1 - "$RUNNER_TEMP/ripgrep-15.2.0/rg" --version + .github/scripts/install-ripgrep.sh 15.2.0 \ + 33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c \ + a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d # Every suite under test/ that names ripgrep or the mandatory-deny scan # it implements, less those whose result does not depend on rg here: @@ -369,32 +334,35 @@ jobs: run: | export PATH="$RUNNER_TEMP/ripgrep-15.2.0:$PATH" rg --version - bun test \ - test/utils/ripgrep.test.ts \ - test/sandbox/mandatory-deny-paths.test.ts \ - test/sandbox/filesystem-disabled.test.ts \ - test/sandbox/linux-ancestor-pin.test.ts \ - test/sandbox/linux-bwrap-args-file.test.ts \ - test/sandbox/linux-file-above-deny.test.ts \ - test/sandbox/linux-mount-plan-record.test.ts \ - test/sandbox/literal-path-entries.test.ts \ - test/sandbox/read-deny-glob.test.ts \ - test/sandbox/readonly-deny-dir-binds.test.ts \ - test/sandbox/readonly-deny-dir-stubs.test.ts \ - test/sandbox/stale-mount-point.test.ts \ + suites=( + test/utils/ripgrep.test.ts + test/sandbox/mandatory-deny-paths.test.ts + test/sandbox/filesystem-disabled.test.ts + test/sandbox/linux-ancestor-pin.test.ts + test/sandbox/linux-bwrap-args-file.test.ts + test/sandbox/linux-file-above-deny.test.ts + test/sandbox/linux-mount-plan-record.test.ts + test/sandbox/literal-path-entries.test.ts + test/sandbox/read-deny-glob.test.ts + test/sandbox/readonly-deny-dir-binds.test.ts + test/sandbox/readonly-deny-dir-stubs.test.ts + test/sandbox/stale-mount-point.test.ts test/sandbox/symlinked-deny-paths.test.ts + ) + # `bun test` takes these as filters and passes over one that matches + # nothing, so a suite that was renamed has to fail the step here. + ls "${suites[@]}" > /dev/null + bun test "${suites[@]}" - # Everything above runs the library under Bun. `npm run test:node` is - # the part of the tree that runs the built package under Node: the - # whichSync fallback, and the control-fd suite, which starts dist/cli.js - # with the node on PATH. It runs on each release line from the engines - # floor up. Only these steps repeat, so the job matrix stays as it is. - - name: Run the tests that use Node (Node 22) + - name: Run Node.js fallback tests if: matrix.os != 'windows' - run: | - node --version - npm run test:node + run: node test/utils/which-node-test.mjs + # Everything above ran with Node 22 on PATH. `npm run test:node` is the + # part of the tree that runs the library under Node: the whichSync + # fallback, and the suites that start `node` themselves. Only it repeats + # on 24, the line above, so the job matrix stays as it is. Later lines, + # which `engines` also admits, are not run. - name: Setup Node.js 24 if: matrix.os != 'windows' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/package.json b/package.json index 4e6e404b9..c51dd78dd 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "build:srt-proxy-node": "bun build src/proxy-main.ts --target=node --format=esm --outfile dist/srt-proxy.mjs", "clean": "rm -rf dist", "test": "bun test", - "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts", + "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts test/utils/real-path.test.ts test/sandbox/default-write-paths.test.ts test/sandbox/rules-through-links-on-every-runtime.test.ts test/sandbox/tls-terminate-tunnel-ends.test.ts", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", diff --git a/src/sandbox/proxy-only.ts b/src/sandbox/proxy-only.ts index dc0d9e174..2e64218dd 100644 --- a/src/sandbox/proxy-only.ts +++ b/src/sandbox/proxy-only.ts @@ -310,16 +310,15 @@ export function openFdStreams(fd: number): { input: Readable.fromWeb(file.stream()), output: new Writable({ write(chunk: Buffer, _encoding, callback) { - try { + // A failed write shows in one of three ways: write() rejects (a + // chunk too large to buffer), flush() throws, or flush() rejects. + // Inside an async function all three reach the callback. + const send = async (): Promise => { sink ??= file.writer() - // write() may give a promise, whose failure has to reach the - // callback too. On a broken pipe flush() throws rather than rejects. - Promise.resolve(sink.write(chunk)) - .then(() => sink?.flush()) - .then(() => callback(), callback) - } catch (err) { - callback(err as Error) + await sink.write(chunk) + await sink.flush() } + send().then(() => callback(), callback) }, final(callback) { Promise.resolve(sink?.end()).then(() => callback(), callback) diff --git a/test/helpers/tls-connect.ts b/test/helpers/tls-connect.ts new file mode 100644 index 000000000..8795eb5cb --- /dev/null +++ b/test/helpers/tls-connect.ts @@ -0,0 +1,17 @@ +import { connect, type ConnectionOptions, type TLSSocket } from 'node:tls' + +/** + * `connect` of 'node:tls', with its types. The Bun type package redeclares it + * in terms of names that @types/node 22 declares in 'tls', not in 'node:tls', + * so as imported it takes and returns an untyped value. + */ +export const tlsConnect: ( + options: ConnectionOptions, + secureConnectListener?: () => void, +) => TLSSocket = connect + +// This, and the `satisfies ConnectionOptions` in parent-proxy.ts, check +// something only while ConnectionOptions is itself a real type. Should it +// become untyped too, the directive goes unused and the type check fails. +// @ts-expect-error -- 'servernme' is not an option +void ({ servernme: 'proxy.example' } satisfies ConnectionOptions) diff --git a/test/proxy-cli.test.ts b/test/proxy-cli.test.ts index 978429555..efef05896 100644 --- a/test/proxy-cli.test.ts +++ b/test/proxy-cli.test.ts @@ -3,7 +3,7 @@ import { spawn, spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { connect, createServer, type Socket } from 'node:net' import type { Writable } from 'node:stream' -import { connect as tlsConnect } from 'node:tls' +import { tlsConnect } from './helpers/tls-connect.js' import { encodeFrame } from '../src/sandbox/decider-client.js' import { generateCa } from '../src/sandbox/mitm-ca.js' import { SERVES_EMITTED_CONNECTIONS } from './helpers/emitted-connections.js' @@ -319,7 +319,7 @@ describe('srt-proxy entry', () => { 'GET /x HTTP/1.1\r\nHost: allowed.test\r\nConnection: close\r\n\r\n', ), ) - t.on('data', (y: Buffer) => (out += y)) + t.on('data', y => (out += y)) t.on('close', () => resolve(out)) t.on('error', reject) } diff --git a/test/sandbox/client-abort.test.ts b/test/sandbox/client-abort.test.ts index 32b97b27f..2c1f76898 100644 --- a/test/sandbox/client-abort.test.ts +++ b/test/sandbox/client-abort.test.ts @@ -8,11 +8,8 @@ import { connect } from 'node:net' import type { AddressInfo } from 'node:net' import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { - connect as tlsConnect, - type ConnectionOptions, - type TLSSocket, -} from 'node:tls' +import type { TLSSocket } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, disposeMitmCA } from '../../src/sandbox/mitm-ca.js' @@ -759,14 +756,8 @@ describe('client aborts through the TLS-terminating path', () => { }) raw.on('error', () => {}) raw.once('data', () => { - // `connect` from 'node:tls' is untyped under the Bun type package - // with @types/node 22, so the options and the socket are typed here. - const tls: TLSSocket = tlsConnect( - { - socket: raw, - ca: CA_PEM, - servername: 'localhost', - } satisfies ConnectionOptions, + const tls = tlsConnect( + { socket: raw, ca: CA_PEM, servername: 'localhost' }, () => fn(tls, () => resolve()), ) tls.on('error', () => resolve()) diff --git a/test/sandbox/client-hello-sni.test.ts b/test/sandbox/client-hello-sni.test.ts index 7d87a857a..79b8b5c2b 100644 --- a/test/sandbox/client-hello-sni.test.ts +++ b/test/sandbox/client-hello-sni.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test' import { createServer, type AddressInfo } from 'node:net' -import { connect as tlsConnect } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { serverNameFromClientHello } from '../../src/sandbox/tls-terminate-proxy.js' const u16 = (n: number): Buffer => Buffer.from([n >> 8, n & 0xff]) diff --git a/test/sandbox/credential-mask-sigv4.test.ts b/test/sandbox/credential-mask-sigv4.test.ts index 9889ef7dc..b82e176c1 100644 --- a/test/sandbox/credential-mask-sigv4.test.ts +++ b/test/sandbox/credential-mask-sigv4.test.ts @@ -21,11 +21,7 @@ import type { IncomingHttpHeaders, IncomingMessage } from 'node:http' import type { Server, AddressInfo } from 'node:net' import { spawn } from 'node:child_process' import { connect as netConnect } from 'node:net' -import { - connect as tlsConnect, - type ConnectionOptions, - type TLSSocket, -} from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -366,16 +362,14 @@ describe('SigV4 re-signing through the TLS-terminating proxy', () => { }) raw.on('error', () => {}) raw.once('data', () => { - // `connect` from 'node:tls' is untyped under the Bun type package - // with @types/node 22, so the options and the socket are typed here. - const tls: TLSSocket = tlsConnect( + const tls = tlsConnect( // SNI cannot carry an IP literal; the CA is pinned, so skip the // hostname identity check instead. { socket: raw, ca: CA_PEM, checkServerIdentity: () => undefined, - } satisfies ConnectionOptions, + }, () => { tls.write( `DELETE /bucket/key HTTP/1.1\r\n` + diff --git a/test/sandbox/deny-late-body.test.ts b/test/sandbox/deny-late-body.test.ts index 3b950a305..f40a31d81 100644 --- a/test/sandbox/deny-late-body.test.ts +++ b/test/sandbox/deny-late-body.test.ts @@ -3,7 +3,7 @@ import { afterEach, expect, test } from 'bun:test' // skipped where this runtime cannot terminate it in-process. import { describeWithTls as describe } from '../helpers/emitted-connections.js' import { connect, type AddressInfo } from 'node:net' -import { connect as tlsConnect } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' @@ -52,7 +52,7 @@ describe('a body that keeps arriving after a deny', () => { }, i * 10) } }) - t.on('data', (d: Buffer) => (out += d)) + t.on('data', d => (out += d)) t.on('close', () => resolve(out)) t.on('error', () => resolve(out)) }) diff --git a/test/sandbox/invalid-upstream-response.test.ts b/test/sandbox/invalid-upstream-response.test.ts index fae20b5de..ee9540120 100644 --- a/test/sandbox/invalid-upstream-response.test.ts +++ b/test/sandbox/invalid-upstream-response.test.ts @@ -10,10 +10,8 @@ import { } from 'node:http' import { createServer, connect, type AddressInfo, type Server } from 'node:net' import type { LookupFunction, Socket } from 'node:net' -import { - createServer as createTlsServer, - connect as tlsConnect, -} from 'node:tls' +import { createServer as createTlsServer } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' diff --git a/test/sandbox/parent-proxy.test.ts b/test/sandbox/parent-proxy.test.ts index b00d0dcae..b1b0585a9 100644 --- a/test/sandbox/parent-proxy.test.ts +++ b/test/sandbox/parent-proxy.test.ts @@ -1,6 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test' import { connect } from 'node:net' -import type { ConnectionOptions } from 'node:tls' import { canonicalizeHost, isValidHost, @@ -13,16 +12,6 @@ import { stripHopByHop, } from '../../src/sandbox/parent-proxy.js' -// Checked by `tsc -p tsconfig.test.json`, not at run time. The TLS dial in -// parent-proxy.ts, and the tests that open a TLS connection themselves, pin -// their options with `satisfies ConnectionOptions`, because `connect` from -// 'node:tls' accepts any value under the Bun type package. That only checks -// anything while ConnectionOptions is itself a real type. If it ever resolves -// to an untyped value as well, the line below compiles, its directive goes -// unused, and the typecheck fails. -// @ts-expect-error -- 'servernme' is not an option, so this must not compile -void ({ servernme: 'proxy.example' } satisfies ConnectionOptions) - describe('parent-proxy: resolveParentProxy', () => { const saved: Record = {} const vars = [ diff --git a/test/sandbox/proxy-only-edges.test.ts b/test/sandbox/proxy-only-edges.test.ts index f39102c93..9dd1654b2 100644 --- a/test/sandbox/proxy-only-edges.test.ts +++ b/test/sandbox/proxy-only-edges.test.ts @@ -1,16 +1,19 @@ import { afterEach, describe, expect, test } from 'bun:test' +import { closeSync, openSync } from 'node:fs' import { createServer, type Server } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' -import { connect as tlsConnect, type TLSSocket } from 'node:tls' +import type { TLSSocket } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' -import { startProxyOnly } from '../../src/sandbox/proxy-only.js' +import { openFdStreams, startProxyOnly } from '../../src/sandbox/proxy-only.js' import { SERVES_EMITTED_CONNECTIONS, testWithTls, } from '../helpers/emitted-connections.js' import { fakeDecider, type Frame } from '../helpers/fake-decider.js' +import { isLinux } from '../helpers/platform.js' const cleanup: Array<() => Promise | void> = [] afterEach(async () => { @@ -324,3 +327,21 @@ describe('TRACE inside a terminated tunnel', () => { }, ) }) + +describe('a write to an inherited fd that fails', () => { + // /dev/full refuses every write. Bun buffers the small chunk, so flush() + // reports it, and hands the large one straight to write(). + test.skipIf(!isLinux).each([1, 1 << 20])( + 'reaches the callback: %d byte(s)', + async size => { + const fd = openSync('/dev/full', 'w') + cleanup.push(() => closeSync(fd)) + const { output } = openFdStreams(fd) + output.on('error', () => {}) + const failure = await new Promise(resolve => + output.write(Buffer.alloc(size), resolve), + ) + expect(failure).toMatchObject({ code: 'ENOSPC' }) + }, + ) +}) diff --git a/test/sandbox/proxy-only.test.ts b/test/sandbox/proxy-only.test.ts index 6f73cce80..fc43ea155 100644 --- a/test/sandbox/proxy-only.test.ts +++ b/test/sandbox/proxy-only.test.ts @@ -14,7 +14,7 @@ import { type Socket, } from 'node:net' import { PassThrough } from 'node:stream' -import { connect as tlsConnect } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createServer as createHttpServer, Server as HttpServer, @@ -611,7 +611,7 @@ function viaTunnel( }) let out = '' t.on('secureConnect', () => t.write(raw(target))) - t.on('data', (x: Buffer) => (out += x)) + t.on('data', x => (out += x)) t.on('error', () => resolve(out)) t.on('close', () => resolve(out)) } @@ -948,7 +948,7 @@ describe('hop-by-hop headers and request trailers never reach the upstream', () t.on('secureConnect', () => t.write(hopRequest('/hop', '/hop', target)), ) - t.on('data', (y: Buffer) => { + t.on('data', y => { out += y if (out.includes('ok')) t.end() }) @@ -1032,7 +1032,7 @@ async function tlsSetup(decide: Parameters[1]) { `GET ${requestTarget} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`, ), ) - t.on('data', (y: Buffer) => (out += y)) + t.on('data', y => (out += y)) t.on('close', () => resolve(out)) t.on('error', () => resolve(out)) } @@ -1564,7 +1564,7 @@ describe('an upload in a TLS-terminated tunnel to a slow upstream is not buffere setTimeout(() => (sentDuringStall = sent), 2500) }) let out = '' - t.on('data', (y: Buffer) => (out += y)) + t.on('data', y => (out += y)) t.on('close', () => resolve(out)) t.on('error', () => resolve(out)) } @@ -1745,7 +1745,7 @@ async function stalledClientResponse(): Promise<{ }, 2000) }) let n = 0 - t.on('data', (d: Buffer) => (n += d.length)) + t.on('data', d => (n += d.length)) t.on('close', () => resolve(n)) t.on('error', () => resolve(n)) }) @@ -1827,10 +1827,7 @@ function sendWithSni( socket: c, ca: s.ca, servername, - checkServerIdentity: ( - _host: string, - cert: { subject: { CN: string } }, - ) => { + checkServerIdentity: (_host, cert) => { cn = String(cert.subject.CN) return undefined }, @@ -1841,7 +1838,7 @@ function sendWithSni( `GET /sni HTTP/1.1\r\nHost: ${s.target}\r\nConnection: close\r\n\r\n`, ), ) - t.on('data', (d: Buffer) => (out += d)) + t.on('data', d => (out += d)) t.on('close', () => resolve({ raw: out, cn })) t.on('error', () => resolve({ raw: out, cn })) }) diff --git a/test/sandbox/request-rewriting.test.ts b/test/sandbox/request-rewriting.test.ts index 14a12bb90..48865f2d7 100644 --- a/test/sandbox/request-rewriting.test.ts +++ b/test/sandbox/request-rewriting.test.ts @@ -11,7 +11,7 @@ import { } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Server } from 'node:net' -import { connect as tlsConnect } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { @@ -207,7 +207,7 @@ function viaTls( ), ) }) - t.on('data', (d: Buffer) => (raw += d)) + t.on('data', d => (raw += d)) t.on('error', reject) t.on('close', () => resolve(parse(raw))) } diff --git a/test/sandbox/request-target-forwarding.test.ts b/test/sandbox/request-target-forwarding.test.ts index a6987c88e..84b52a7c4 100644 --- a/test/sandbox/request-target-forwarding.test.ts +++ b/test/sandbox/request-target-forwarding.test.ts @@ -4,10 +4,8 @@ import { test, expect, beforeAll, afterAll, spyOn } from 'bun:test' import { describeWithTls as describe } from '../helpers/emitted-connections.js' import { connect, createServer, type AddressInfo, type Server } from 'node:net' import type { LookupFunction, Socket } from 'node:net' -import { - connect as tlsConnect, - createServer as createTlsServer, -} from 'node:tls' +import { createServer as createTlsServer } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/test/sandbox/response-header-bytes.test.ts b/test/sandbox/response-header-bytes.test.ts index 2f8ac4200..a89dec407 100644 --- a/test/sandbox/response-header-bytes.test.ts +++ b/test/sandbox/response-header-bytes.test.ts @@ -7,11 +7,8 @@ import { type Server, type Socket, } from 'node:net' -import { - connect as tlsConnect, - createServer as createTlsServer, - type TLSSocket, -} from 'node:tls' +import { createServer as createTlsServer, type TLSSocket } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' diff --git a/test/sandbox/tls-terminate-destroy-soon.test.ts b/test/sandbox/tls-terminate-destroy-soon.test.ts index eb19cfa0d..cde7072f6 100644 --- a/test/sandbox/tls-terminate-destroy-soon.test.ts +++ b/test/sandbox/tls-terminate-destroy-soon.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' import { createServer, type AddressInfo } from 'node:net' import { join } from 'node:path' -import { connect, type TLSSocket } from 'node:tls' +import type { TLSSocket } from 'node:tls' +import { tlsConnect as connect } from '../helpers/tls-connect.js' import { createMitmCA } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' import { serverTlsOn } from '../../src/sandbox/tls-terminate-proxy.js' @@ -62,7 +63,7 @@ async function lateClientOf( let ended = false c.pause() setTimeout(() => c.resume(), 300) - c.on('data', (d: Buffer) => { + c.on('data', d => { read += d.length c.pause() setTimeout(() => c.resume(), 5) diff --git a/test/sandbox/tls-terminate-handshake-deadline.test.ts b/test/sandbox/tls-terminate-handshake-deadline.test.ts index 4a910ef57..0e018005d 100644 --- a/test/sandbox/tls-terminate-handshake-deadline.test.ts +++ b/test/sandbox/tls-terminate-handshake-deadline.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, test } from 'bun:test' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' import { Duplex } from 'node:stream' -import { connect as tlsConnect, type SecureVersion } from 'node:tls' +import type { SecureVersion } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' diff --git a/test/sandbox/tls-terminate-in-process.test.ts b/test/sandbox/tls-terminate-in-process.test.ts index 5ed6401cc..58defaca0 100644 --- a/test/sandbox/tls-terminate-in-process.test.ts +++ b/test/sandbox/tls-terminate-in-process.test.ts @@ -16,7 +16,8 @@ import { type AddressInfo, type Socket, } from 'node:net' -import { connect as tlsConnect, type TLSSocket } from 'node:tls' +import type { TLSSocket } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' @@ -123,7 +124,7 @@ async function tlsSetup(extra: { requireHostMatch?: boolean } = {}) { `GET ${requestTarget} HTTP/1.1\r\nHost: ${host}\r\nConnection: close\r\n\r\n`, ), ) - t.on('data', (y: Buffer) => (out += y)) + t.on('data', y => (out += y)) t.on('close', () => resolve(out)) t.on('error', () => resolve(out)) } @@ -299,7 +300,7 @@ async function stalledClientResponse(): Promise<{ }, 2000) }) let n = 0 - t.on('data', (d: Buffer) => (n += d.length)) + t.on('data', d => (n += d.length)) t.on('close', () => resolve(n)) t.on('error', () => resolve(n)) }) @@ -381,10 +382,7 @@ function sendWithSni( socket: c, ca: s.ca, servername, - checkServerIdentity: ( - _host: string, - cert: { subject: { CN: string } }, - ) => { + checkServerIdentity: (_host, cert) => { cn = String(cert.subject.CN) return undefined }, @@ -395,7 +393,7 @@ function sendWithSni( `GET /sni HTTP/1.1\r\nHost: ${s.target}\r\nConnection: close\r\n\r\n`, ), ) - t.on('data', (d: Buffer) => (out += d)) + t.on('data', d => (out += d)) t.on('close', () => resolve({ raw: out, cn })) t.on('error', () => resolve({ raw: out, cn })) }) diff --git a/test/sandbox/tls-terminate-keepalive.test.ts b/test/sandbox/tls-terminate-keepalive.test.ts index d8208677f..392eaf203 100644 --- a/test/sandbox/tls-terminate-keepalive.test.ts +++ b/test/sandbox/tls-terminate-keepalive.test.ts @@ -5,11 +5,8 @@ import { describeWithTls as describe } from '../helpers/emitted-connections.js' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Server, type Socket } from 'node:net' import type { LookupFunction } from 'node:net' -import { - connect as tlsConnect, - type ConnectionOptions, - type TLSSocket, -} from 'node:tls' +import type { TLSSocket } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' @@ -43,14 +40,8 @@ function openTunnel( }) raw.once('error', reject) raw.once('data', () => { - // `connect` from 'node:tls' is untyped under the Bun type package with - // @types/node 22, so the options and the socket are typed here. - const tls: TLSSocket = tlsConnect( - { - socket: raw, - ca: CA_PEM, - servername: host, - } satisfies ConnectionOptions, + const tls = tlsConnect( + { socket: raw, ca: CA_PEM, servername: host }, () => resolve(tls), ) tls.once('error', reject) diff --git a/test/sandbox/tls-terminate-relay-budget.test.ts b/test/sandbox/tls-terminate-relay-budget.test.ts index cafb96ff9..db806bb4f 100644 --- a/test/sandbox/tls-terminate-relay-budget.test.ts +++ b/test/sandbox/tls-terminate-relay-budget.test.ts @@ -6,7 +6,7 @@ import { createHash } from 'node:crypto' import { request } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' -import { connect as tlsConnect } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createByteBudget } from '../../src/sandbox/emitted-connection.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' diff --git a/test/sandbox/tls-terminate-upload-gate.test.ts b/test/sandbox/tls-terminate-upload-gate.test.ts index 7cadff723..bb2dc261b 100644 --- a/test/sandbox/tls-terminate-upload-gate.test.ts +++ b/test/sandbox/tls-terminate-upload-gate.test.ts @@ -6,11 +6,8 @@ import { type AddressInfo, type Socket, } from 'node:net' -import { - connect as tlsConnect, - createServer as createTlsServer, - type TLSSocket, -} from 'node:tls' +import { createServer as createTlsServer, type TLSSocket } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createByteBudget } from '../../src/sandbox/emitted-connection.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' diff --git a/test/sandbox/trailers-then-request.test.ts b/test/sandbox/trailers-then-request.test.ts index 3f7586717..211f97b7c 100644 --- a/test/sandbox/trailers-then-request.test.ts +++ b/test/sandbox/trailers-then-request.test.ts @@ -7,7 +7,8 @@ import { } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' -import { connect as tlsConnect, type TLSSocket } from 'node:tls' +import type { TLSSocket } from 'node:tls' +import { tlsConnect } from '../helpers/tls-connect.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' From bc03da8f052d7b95c37b53e59133c7213ba0e0c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:10:59 +0000 Subject: [PATCH 11/15] ci: what a review of the whole change found, in the new legs The smoke test: - On Linux a missing helper or ripgrep fails the run. It used to drop the seccomp check and both multicall modes, and exit 0. - Two checks could not fail. The dependency check came after an initialize() that throws for every error: it now reads the warning a helper that is not found gives. "The network is closed" connected to an outside address, so a host with no route passed: it now connects to a listener on the host, which the same words reach from outside the sandbox. - A failed check throws, so the clean-up runs. A program that cannot be started is reported with its reason. The hint that dist/ is not built could not be reached, and goes. - An ARGV0 or SRT_SMOKE_* inherited from the caller's shell no longer decides what the program runs as. Host paths are quoted in the wrapped command. The workflow: - The Alpine leg runs the seccomp helper the step before it built, static against glibc: that is the file the npm package brings to a musl host. It used to build another against musl, which nobody ships. - Lint runs after the build. Without dist/ the rules that read types saw `any` throughout test/smoke. - The two installs of bun share .github/scripts/install-bun.sh, which retries. The Alpine script has pipefail for its `tar | tar`. - macOS 26 is a `label` of its leg, so `matrix.arch` stays an architecture. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- .github/scripts/install-bun.sh | 24 +++++++++ .github/workflows/integration-tests.yml | 71 ++++++++++--------------- test/smoke/embedder.ts | 57 ++++++++++++++------ test/smoke/run-bundled.ts | 47 +++++++++------- 4 files changed, 120 insertions(+), 79 deletions(-) create mode 100755 .github/scripts/install-bun.sh diff --git a/.github/scripts/install-bun.sh b/.github/scripts/install-bun.sh new file mode 100755 index 000000000..09e99be65 --- /dev/null +++ b/.github/scripts/install-bun.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# install-bun.sh [-musl] +# +# For a container: puts bun in /usr/local/bin, at the version the rest of the +# matrix runs (oven-sh/setup-bun). The tree carries a dozen workarounds for +# one bun version or another, so "whatever bun shipped this morning" is not +# the runtime under test. The release archive, by checksum, rather than an +# installer piped into a shell. +set -eu + +case "$(uname -m)" in + x86_64) zip=bun-linux-x64${3-} sha256=$1 ;; + aarch64) zip=bun-linux-aarch64${3-} sha256=$2 ;; + *) + echo "no bun archive is pinned for $(uname -m)" + exit 1 + ;; +esac + +curl -fsSL --retry 3 -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/$zip.zip" +echo "$sha256 /tmp/bun.zip" | sha256sum -c - +unzip -q /tmp/bun.zip -d /tmp +install -m 0755 "/tmp/$zip/bun" /usr/local/bin/bun +bun --version diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0a2d125df..e5c1c05c3 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -8,7 +8,7 @@ on: jobs: integration-tests: - name: Tests (${{ matrix.os }} / ${{ matrix.arch }}) + name: Tests (${{ matrix.os }} / ${{ matrix.label || matrix.arch }}) runs-on: ${{ matrix.runner }} strategy: @@ -30,7 +30,8 @@ jobs: # The newest macOS as well: the sandbox profile's own regex engine # and what Seatbelt refuses have both moved between releases, and # the two legs above stay on the images they name. - - arch: arm64-macos26 + - arch: arm64 + label: arm64-macos26 runner: macos-26 os: macos # Windows legs build srt-win, run the Rust smoke scripts, @@ -230,12 +231,9 @@ jobs: if: matrix.os != 'windows' run: npm ci - - name: Lint (ESLint) - if: matrix.os == 'linux' && matrix.arch == 'x86-64' - run: npx eslint . - # `npm run build` compiles src only. This is the one step that checks - # the types of test/, which bun strips at runtime. + # the types of test/, which bun strips at runtime, save test/smoke, + # which imports the built package: `npm run test:bundled` checks that. - name: Typecheck if: matrix.os != 'windows' run: npm run typecheck @@ -252,6 +250,12 @@ jobs: if: matrix.os != 'windows' run: npm run build + # After the build: without dist/ the rules that read types see `any` + # throughout test/smoke, and report nothing there. + - name: Lint (ESLint) + if: matrix.os == 'linux' && matrix.arch == 'x86-64' + run: npx eslint . + - name: Run tests if: matrix.os != 'windows' run: npm test @@ -394,7 +398,7 @@ jobs: if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: test-results-${{ matrix.os }}-${{ matrix.arch }} + name: test-results-${{ matrix.os }}-${{ matrix.label || matrix.arch }} path: | test-results/ *.log @@ -448,25 +452,13 @@ jobs: apt-get update -qq # node and npm are those of the runner, bound in, and not those # of the image, which has Node 18, below what engines in - # package.json allows. bun is pinned to the version the rest of - # the matrix runs (oven-sh/setup-bun above). The tree carries a - # dozen bun-version-specific workarounds, so "whatever bun - # shipped this morning" is not the runtime under test. + # package.json allows. apt-get install -y -qq bubblewrap socat ripgrep python3 curl ca-certificates unzip gcc libseccomp-dev libcap2-bin export PATH="/opt/node/bin:$PATH" node --version - # The release archive, by checksum, rather than an installer - # piped into a shell. - case "$(uname -m)" in - x86_64) bun_zip=bun-linux-x64; bun_sha=36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 ;; - aarch64) bun_zip=bun-linux-aarch64; bun_sha=54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 ;; - *) echo "no bun archive is pinned for $(uname -m)"; exit 1 ;; - esac - curl -fsSL -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/${bun_zip}.zip" - echo "${bun_sha} /tmp/bun.zip" | sha256sum -c - - unzip -q /tmp/bun.zip -d /tmp - install -m 0755 "/tmp/${bun_zip}/bun" /usr/local/bin/bun - bun --version + .github/scripts/install-bun.sh \ + 36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 \ + 54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 npm ci npm run build:seccomp npm run build @@ -476,11 +468,11 @@ jobs: capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" ' - # The same two runs on musl. The seccomp helper is static and built - # here against musl's libseccomp, and bubblewrap, socat and ripgrep are - # Alpine's own builds, and bun is its musl build. The checkout - # is copied first: node_modules and the helper the step above left in - # the workspace were built for glibc. + # The same two runs on musl: bubblewrap, socat and ripgrep are Alpine's + # own builds, and bun is its musl build. The seccomp helper is the one + # the step above built, static against glibc, because that is the file + # the npm package brings to a musl host. The rest of the checkout is + # copied without what that step installed for glibc. - name: Run srt end-to-end as uid 0 in an Alpine container run: | docker run --rm \ @@ -490,23 +482,16 @@ jobs: -v "${{ github.workspace }}:/src:ro" \ -e SRT_E2E_DOCKER=1 \ alpine:3.22 \ - sh -euc ' - apk add --no-cache bash bubblewrap socat ripgrep python3 curl ca-certificates unzip gcc musl-dev linux-headers libseccomp-dev libseccomp-static libcap-utils libstdc++ libgcc nodejs npm tar + sh -euo pipefail -c ' + apk add --no-cache bash bubblewrap socat ripgrep python3 curl ca-certificates unzip libcap-utils libstdc++ libgcc nodejs npm tar mkdir /work - tar -C /src --exclude=./node_modules --exclude=./dist --exclude=./vendor/seccomp/x64 --exclude=./vendor/seccomp/arm64 --exclude=./.git -cf - . | tar -C /work -xf - + tar -C /src --exclude=./node_modules --exclude=./dist --exclude=./.git -cf - . | tar -C /work -xf - cd /work - case "$(uname -m)" in - x86_64) bun_zip=bun-linux-x64-musl; bun_sha=4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742 ;; - aarch64) bun_zip=bun-linux-aarch64-musl; bun_sha=71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df ;; - *) echo "no bun archive is pinned for $(uname -m)"; exit 1 ;; - esac - curl -fsSL -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/${bun_zip}.zip" - echo "${bun_sha} /tmp/bun.zip" | sha256sum -c - - unzip -q /tmp/bun.zip -d /tmp - install -m 0755 "/tmp/${bun_zip}/bun" /usr/local/bin/bun - bun --version + .github/scripts/install-bun.sh \ + 4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742 \ + 71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df \ + -musl npm ci - npm run build:seccomp npm run build echo "== as uid 0 with CAP_SYS_ADMIN" bun test test/docker-weak-sandbox.test.ts diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts index b68ee0945..5af85207d 100644 --- a/test/smoke/embedder.ts +++ b/test/smoke/embedder.ts @@ -39,6 +39,8 @@ import { realpathSync, rmSync, } from 'node:fs' +import { once } from 'node:events' +import { createServer, type AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' @@ -49,7 +51,10 @@ import { basename, join } from 'node:path' * can set argv[0], and ripgrep through argv[0] itself. */ function runAsAnotherProgram(): void { - const name = process.env.ARGV0 ?? basename(process.argv0) + const name = + process.env.ARGV0 === 'apply-seccomp' + ? process.env.ARGV0 + : basename(process.argv0) const real = name === 'apply-seccomp' ? process.env.SRT_SMOKE_APPLY_SECCOMP @@ -82,13 +87,16 @@ function runAsAnotherProgram(): void { runAsAnotherProgram() function check(what: string, ok: boolean, detail = ''): void { - if (!ok) { - console.error(`FAILED: ${what}${detail === '' ? '' : `\n${detail}`}`) - process.exit(1) - } + if (!ok) + throw new Error(`FAILED: ${what}${detail === '' ? '' : `\n${detail}`}`) console.log(`ok: ${what}`) } +/** One word of a shell command. */ +function quoted(word: string): string { + return `'${word.replace(/'/g, `'\\''`)}'` +} + // Bytecode is CommonJS, which has no top-level await. async function main(): Promise { const { SandboxManager, LinuxSandboxProfileError } = await import( @@ -114,7 +122,9 @@ async function main(): Promise { // shim above writes a line where a wrapped command can write, each time it // is started as one of them. const multicallLog = join(work, 'multicall.log') + const closing: Array<() => void> = [] const self = multicall ? openSync(process.execPath, 'r') : undefined + if (self !== undefined) closing.push(() => closeSync(self)) if (multicall) process.env.SRT_SMOKE_MULTICALL_LOG = multicallLog const tools = multicall ? { @@ -139,21 +149,35 @@ async function main(): Promise { }) // After initialize, so that the helper the configuration names is the - // one that is looked for. - const dependencies = SandboxManager.checkDependencies() + // one that is looked for. A helper that is not found is a warning: + // initialize() has thrown already for what would be an error. + const { warnings } = SandboxManager.checkDependencies() + check( + 'the dependency check finds the seccomp helper', + !helperExpected || !warnings.some(w => w.includes('seccomp')), + warnings.join('\n'), + ) + + // A listener on the host, which the same words reach from outside the + // sandbox: without that, "blocked" could mean a shell that cannot connect. + const listener = createServer().listen(0, '127.0.0.1') + closing.push(() => listener.close()) + await once(listener, 'listening') + const { port } = listener.address() as AddressInfo + const connect = `(exec 9<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED` check( - 'the dependency check finds nothing missing', - dependencies.errors.length === 0, - dependencies.errors.join('\n'), + 'the listener is reachable from outside the sandbox', + spawnSync('bash', ['-c', connect], { encoding: 'utf8' }).stdout === + 'NET-OPEN\n', ) const wrapped = await SandboxManager.wrapWithSandbox( [ 'echo BOOTED', - `echo allowed > ${join(work, 'allowed.txt')}`, - `echo denied > ${join(work, 'denied.txt')} 2>/dev/null || echo WRITE-DENIED`, - `echo outside > ${join(outside, 'x.txt')} 2>/dev/null || echo OUTSIDE-DENIED`, - '(exec 9<>/dev/tcp/1.1.1.1/80) 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED', + `echo allowed > ${quoted(join(work, 'allowed.txt'))}`, + `echo denied > ${quoted(join(work, 'denied.txt'))} 2>/dev/null || echo WRITE-DENIED`, + `echo outside > ${quoted(join(outside, 'x.txt'))} 2>/dev/null || echo OUTSIDE-DENIED`, + connect, 'grep -q "^Seccomp:[[:space:]]*2" /proc/self/status 2>/dev/null && echo SECCOMP-ON || echo SECCOMP-OFF', 'exit 7', ].join('; '), @@ -268,13 +292,14 @@ async function main(): Promise { } console.log('SMOKE OK') } finally { - if (self !== undefined) closeSync(self) + for (const close of closing) close() await SandboxManager.reset() rmSync(root, { recursive: true, force: true }) } } main().catch((error: unknown) => { - console.error(error) + // Not the error itself: the runtime would print the minified source with it. + console.error(error instanceof Error ? error.stack : error) process.exit(1) }) diff --git a/test/smoke/run-bundled.ts b/test/smoke/run-bundled.ts index 855261dca..ad595a897 100644 --- a/test/smoke/run-bundled.ts +++ b/test/smoke/run-bundled.ts @@ -10,7 +10,9 @@ * an empty directory and run from there. Nothing of the package is on disk * around it then, which is how an embedded copy runs: a lookup that starts * from the library's own location finds nothing, and has to be one the - * configuration already answered. + * configuration already answered. (Not quite nothing here: a bundle records + * where its modules were when it was built, so on the machine that built it + * such a lookup still reaches this checkout.) * * On Linux each is run twice: told where the helper file is, and as a * multicall executable that carries the helper and ripgrep itself and is @@ -21,7 +23,6 @@ import { spawnSync } from 'node:child_process' import { chmodSync, copyFileSync, - existsSync, mkdtempSync, realpathSync, rmSync, @@ -31,10 +32,6 @@ import { join } from 'node:path' const repo = process.cwd() const entry = join(repo, 'test', 'smoke', 'embedder.ts') -if (!existsSync(join(repo, 'dist', 'index.js'))) { - console.error('dist/ is not built: run `npm run build` first') - process.exit(1) -} const FORMS: Array<{ name: string; flags: string[] }> = [ { name: 'one file', flags: [] }, @@ -76,23 +73,30 @@ for (const form of FORMS) { continue } - const env: Record = { ...process.env } - delete env.SRT_SMOKE_APPLY_SECCOMP - if (process.platform === 'linux' && existsSync(builtHelper)) { + // Less what the program itself reads, should the caller's shell hold it. + const env = Object.fromEntries( + Object.entries(process.env).filter( + ([name]) => name !== 'ARGV0' && !name.startsWith('SRT_SMOKE_'), + ), + ) + const modes: Array<{ name: string; env: typeof env }> = [ + { name: form.name, env }, + ] + if (process.platform === 'linux') { + // Both are there on every Linux leg. Were one not, copyFileSync or the + // program would fail: a run that skipped these modes would prove less + // than it says. const helper = join(home, 'apply-seccomp') copyFileSync(builtHelper, helper) chmodSync(helper, 0o755) env.SRT_SMOKE_APPLY_SECCOMP = helper - } - - const modes: Array<{ name: string; env: typeof env }> = [ - { name: form.name, env }, - ] - const ripgrep = Bun.which('rg') - if (env.SRT_SMOKE_APPLY_SECCOMP !== undefined && ripgrep !== null) { modes.push({ name: `${form.name}, helper and ripgrep reached through argv0`, - env: { ...env, SRT_SMOKE_MULTICALL: '1', SRT_SMOKE_RG: ripgrep }, + env: { + ...env, + SRT_SMOKE_MULTICALL: '1', + SRT_SMOKE_RG: Bun.which('rg') ?? undefined, + }, }) } for (const mode of modes) { @@ -103,10 +107,13 @@ for (const form of FORMS) { encoding: 'utf8', timeout: 300000, }) - process.stdout.write(run.stdout) - process.stderr.write(run.stderr) + // Both are null for a program that could not be started. + process.stdout.write(run.stdout ?? '') + process.stderr.write(run.stderr ?? '') if (run.status !== 0 || !run.stdout.includes('SMOKE OK')) { - console.error(`== ${mode.name}: FAILED (status ${String(run.status)})`) + console.error( + `== ${mode.name}: FAILED (status ${String(run.status)}${run.error === undefined ? '' : `, ${String(run.error)}`})`, + ) failed = true } } From 9c54fab50d62204a3cae6a384b8370a7d38c198b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:23:33 +0000 Subject: [PATCH 12/15] ci: a simplification pass over the whole change - tls.connect() is untyped because the Bun type package redeclares it in 'node:tls' with three names that @types/node 22 declares in 'tls' alone. src/node-tls.d.ts gives the module those names. The helper, the import lines in eighteen suites and the `satisfies` in parent-proxy.ts all go: a misspelt option is refused and the result is a TLSSocket wherever it is imported, in new code too. Should the file stop working, seven suites fail the type check. - ripgrep 15.2.0 goes on PATH for the second full run, the one on bubblewrap 0.12.0, in place of a list of thirteen suites kept by hand. Every suite now sees it, a new one included, and the step, its list and the check that each name exists go. That run varies two tools at once. - One step installs both releases, so the script is a function in it. - Comments are cut to what the next reader needs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- .github/scripts/install-ripgrep.sh | 25 ----- .github/workflows/integration-tests.yml | 96 ++++++------------- src/node-tls.d.ts | 9 ++ src/sandbox/http-proxy.ts | 6 +- src/sandbox/parent-proxy.ts | 8 +- test/helpers/tls-connect.ts | 17 ---- test/proxy-cli.test.ts | 2 +- test/sandbox/client-abort.test.ts | 3 +- test/sandbox/client-hello-sni.test.ts | 2 +- test/sandbox/credential-mask-sigv4.test.ts | 2 +- test/sandbox/deny-late-body.test.ts | 2 +- .../sandbox/invalid-upstream-response.test.ts | 6 +- test/sandbox/proxy-only-edges.test.ts | 3 +- test/sandbox/proxy-only.test.ts | 2 +- test/sandbox/request-rewriting.test.ts | 2 +- .../sandbox/request-target-forwarding.test.ts | 6 +- test/sandbox/response-header-bytes.test.ts | 7 +- .../tls-terminate-destroy-soon.test.ts | 3 +- .../tls-terminate-handshake-deadline.test.ts | 3 +- test/sandbox/tls-terminate-in-process.test.ts | 3 +- test/sandbox/tls-terminate-keepalive.test.ts | 3 +- .../tls-terminate-relay-budget.test.ts | 2 +- .../sandbox/tls-terminate-upload-gate.test.ts | 7 +- test/sandbox/trailers-then-request.test.ts | 3 +- 24 files changed, 73 insertions(+), 149 deletions(-) delete mode 100755 .github/scripts/install-ripgrep.sh create mode 100644 src/node-tls.d.ts delete mode 100644 test/helpers/tls-connect.ts diff --git a/.github/scripts/install-ripgrep.sh b/.github/scripts/install-ripgrep.sh deleted file mode 100755 index c9957e8b9..000000000 --- a/.github/scripts/install-ripgrep.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# install-ripgrep.sh -# -# Unpacks that ripgrep release into $RUNNER_TEMP/ripgrep-, from the -# upstream release instead of whichever version the package archive serves -# that day. Each checksum is the one upstream publishes beside the archive, -# and a mismatch fails the step. -set -euo pipefail - -version=$1 -case "$(uname -m)" in - x86_64) asset=ripgrep-$version-x86_64-unknown-linux-musl.tar.gz sha256=$2 ;; - aarch64) asset=ripgrep-$version-aarch64-unknown-linux-gnu.tar.gz sha256=$3 ;; - *) - echo "::error::no ripgrep archive is pinned for $(uname -m)" - exit 1 - ;; -esac -into=$RUNNER_TEMP/ripgrep-$version - -curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/$version/$asset" -echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict -mkdir -p "$into" -tar -xzf "$RUNNER_TEMP/$asset" -C "$into" --strip-components=1 -"$into/rg" --version diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 7155860dd..f921f298a 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -47,9 +47,7 @@ jobs: # 22.12.0 itself: these tests also run `node` inside the sandbox, # and on Windows that release tries only `::1` for `localhost`, # where the proxy listens on 127.0.0.1. The floor itself is run by - # the package check in release.yml. The steps at the end of the job - # that run the built package under Node repeat on 24, the line - # above it. + # the package check in release.yml. node-version: '22' - name: Setup Bun @@ -182,17 +180,32 @@ jobs: sudo apt-get install -y bubblewrap libseccomp-dev gcc socat apparmor-profiles zsh # The library reads both the output and the exit status of ripgrep, and - # those differ between ripgrep releases: for an unreadable directory - # that sits exactly at --max-depth, 14.1.1 prints "Permission denied" - # and exits 2, where 15.2.0 passes over it and exits 0. 14.1.1 is the - # release most installations have, so it is the one the main run uses. - - name: Install ripgrep 14.1.1 (Linux) + # those differ between releases: for an unreadable directory exactly at + # --max-depth, 14.1.1 prints "Permission denied" and exits 2, where + # 15.2.0 passes over it and exits 0. 14.1.1 is what most installations + # have, so every later step finds it first on PATH; the second full run + # below puts 15.2.0 ahead of it. Both are upstream's archives, by the + # checksums upstream publishes. macOS runs whatever Homebrew serves. + - name: Install ripgrep 14.1.1 and 15.2.0 (Linux) if: matrix.os == 'linux' run: | - .github/scripts/install-ripgrep.sh 14.1.1 \ + install_ripgrep() { + case "${{ matrix.arch }}" in + x86-64) asset=ripgrep-$1-x86_64-unknown-linux-musl.tar.gz sha256=$2 ;; + arm64) asset=ripgrep-$1-aarch64-unknown-linux-gnu.tar.gz sha256=$3 ;; + esac + curl -fsSL --retry 3 -o "$RUNNER_TEMP/$asset" "https://github.com/BurntSushi/ripgrep/releases/download/$1/$asset" + echo "$sha256 $RUNNER_TEMP/$asset" | sha256sum --check --strict + mkdir "$RUNNER_TEMP/ripgrep-$1" + tar -xzf "$RUNNER_TEMP/$asset" -C "$RUNNER_TEMP/ripgrep-$1" --strip-components=1 + "$RUNNER_TEMP/ripgrep-$1/rg" --version + } + install_ripgrep 14.1.1 \ 4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e \ c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f - # Ahead of everything already on PATH, for every later step. + install_ripgrep 15.2.0 \ + 33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c \ + a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d echo "$RUNNER_TEMP/ripgrep-14.1.1" >> "$GITHUB_PATH" - name: Enable unprivileged user namespaces (Linux) @@ -217,9 +230,6 @@ jobs: run: | brew install ripgrep zsh - # `npm ci`, like the Windows legs above: it installs exactly what - # package-lock.json records and fails when package.json has moved away - # from it, where `npm install` would resolve again and carry on. - name: Install Node dependencies if: matrix.os != 'windows' run: npm ci @@ -268,9 +278,9 @@ jobs: ninja -C _build install "$RUNNER_TEMP/bwrap-0.12/bin/bwrap" --version - - name: Run tests against bubblewrap 0.12.0 (Linux) + - name: Run tests against bubblewrap 0.12.0 and ripgrep 15.2.0 (Linux) if: matrix.os == 'linux' - run: PATH="$RUNNER_TEMP/bwrap-0.12/bin:$PATH" npm test + run: PATH="$RUNNER_TEMP/bwrap-0.12/bin:$RUNNER_TEMP/ripgrep-15.2.0:$PATH" npm test # RHEL 8 and 9 ship bubblewrap 0.4.0 and 0.4.1, the oldest versions in # current distribution use, and both refuse to start on a plan that @@ -309,60 +319,12 @@ jobs: test/sandbox/symlink-boundary.test.ts \ test/sandbox/stale-mount-point.test.ts - # A release from the 15.x line, pinned like the one above. Over the same - # tree its scan can exit with a different status from 14.1.1 (see the - # install step above), so the suites that depend on the scan run against - # it as well. The macOS legs are not pinned to either: they run on - # whichever release Homebrew serves. - - name: Install ripgrep 15.2.0 (Linux) - if: matrix.os == 'linux' - run: | - .github/scripts/install-ripgrep.sh 15.2.0 \ - 33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c \ - a740b91c82eaf9914cfedd353572f2791cbe0162c84101ee0951058f4dcbc90d - - # Every suite under test/ that names ripgrep or the mandatory-deny scan - # it implements, less those whose result does not depend on rg here: - # config-validation (schema only), public-exports (the export list), - # check-dependencies (replaces the lookup with a spy), glob-expand (puts - # a stand-in in its place), deny-order-independence and - # linux-one-mount-per-destination (keep the working directory where the - # scan adds nothing), macos-literal-deny-brackets (macOS only) and - # winsrt (Windows only). - - name: Run the ripgrep-dependent tests against ripgrep 15.2.0 (Linux) - if: matrix.os == 'linux' - run: | - export PATH="$RUNNER_TEMP/ripgrep-15.2.0:$PATH" - rg --version - suites=( - test/utils/ripgrep.test.ts - test/sandbox/mandatory-deny-paths.test.ts - test/sandbox/filesystem-disabled.test.ts - test/sandbox/linux-ancestor-pin.test.ts - test/sandbox/linux-bwrap-args-file.test.ts - test/sandbox/linux-file-above-deny.test.ts - test/sandbox/linux-mount-plan-record.test.ts - test/sandbox/literal-path-entries.test.ts - test/sandbox/read-deny-glob.test.ts - test/sandbox/readonly-deny-dir-binds.test.ts - test/sandbox/readonly-deny-dir-stubs.test.ts - test/sandbox/stale-mount-point.test.ts - test/sandbox/symlinked-deny-paths.test.ts - ) - # `bun test` takes these as filters and passes over one that matches - # nothing, so a suite that was renamed has to fail the step here. - ls "${suites[@]}" > /dev/null - bun test "${suites[@]}" - - name: Run Node.js fallback tests if: matrix.os != 'windows' run: node test/utils/which-node-test.mjs - # Everything above ran with Node 22 on PATH. `npm run test:node` is the - # part of the tree that runs the library under Node: the whichSync - # fallback, and the suites that start `node` themselves. Only it repeats - # on 24, the line above, so the job matrix stays as it is. Later lines, - # which `engines` also admits, are not run. + # Everything above ran with Node 22 on PATH. What runs the library under + # Node repeats on 24, in a step so that the matrix stays as it is. - name: Setup Node.js 24 if: matrix.os != 'windows' uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -371,9 +333,7 @@ jobs: - name: Run the tests that use Node (Node 24) if: matrix.os != 'windows' - run: | - node --version - npm run test:node + run: npm run test:node - name: Upload test results if: always() diff --git a/src/node-tls.d.ts b/src/node-tls.d.ts new file mode 100644 index 000000000..2839445da --- /dev/null +++ b/src/node-tls.d.ts @@ -0,0 +1,9 @@ +// The Bun type package redeclares `connect` in 'node:tls' in terms of names +// that @types/node 22 declares in 'tls' alone. Unresolved, they leave it +// taking and returning an untyped value. +declare module 'node:tls' { + import * as tls from 'tls' + export import ConnectionOptions = tls.ConnectionOptions + export import KeyObject = tls.KeyObject + export import TLSSocket = tls.TLSSocket +} diff --git a/src/sandbox/http-proxy.ts b/src/sandbox/http-proxy.ts index dc0eb160b..9208a7bfe 100644 --- a/src/sandbox/http-proxy.ts +++ b/src/sandbox/http-proxy.ts @@ -499,10 +499,8 @@ export function createHttpProxyServer(options: HttpProxyServerOptions): Server { } } - // Handle CONNECT requests for HTTPS traffic. `head` is typed as a plain - // Buffer because it is reassigned below with bytes read off the socket; - // the listener signature alone would narrow it to a Buffer over a - // non-shared ArrayBuffer, which those reads are not declared to return. + // Handle CONNECT requests for HTTPS traffic. `head` is widened from the + // listener's NonSharedBuffer: it is reassigned below from socket reads. server.on('connect', async (req, socket, head: Buffer) => { // Attach error handler immediately to prevent unhandled errors socket.on('error', err => { diff --git a/src/sandbox/parent-proxy.ts b/src/sandbox/parent-proxy.ts index a5af514e4..9db18debe 100644 --- a/src/sandbox/parent-proxy.ts +++ b/src/sandbox/parent-proxy.ts @@ -23,7 +23,7 @@ import type { ServerResponse, } from 'node:http' import { BlockList, connect as netConnect, isIP } from 'node:net' -import { connect as tlsConnect, type ConnectionOptions } from 'node:tls' +import { connect as tlsConnect } from 'node:tls' import { URL } from 'node:url' import { logForDebugging } from '../utils/debug.js' import type { ParentProxyConfig } from './sandbox-config.js' @@ -339,10 +339,6 @@ export function connectViaParentProxy( destPort, authHeader: proxyAuthHeader(proxyUrl), readyEvent: useTls ? 'secureConnect' : 'connect', - // The TLS options are checked with `satisfies` because the call does not - // check them itself: the Bun type package redeclares `connect` for - // 'node:tls', and resolved against @types/node 22 that declaration takes - // and returns an untyped value. dial: () => useTls ? tlsConnect({ @@ -350,7 +346,7 @@ export function connectViaParentProxy( port: proxyPort, // SNI must be a hostname, never an IP literal (RFC 6066 §3). ...(isIP(proxyHost) ? {} : { servername: proxyHost }), - } satisfies ConnectionOptions) + }) : netConnect(proxyPort, proxyHost), }) } diff --git a/test/helpers/tls-connect.ts b/test/helpers/tls-connect.ts deleted file mode 100644 index 8795eb5cb..000000000 --- a/test/helpers/tls-connect.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { connect, type ConnectionOptions, type TLSSocket } from 'node:tls' - -/** - * `connect` of 'node:tls', with its types. The Bun type package redeclares it - * in terms of names that @types/node 22 declares in 'tls', not in 'node:tls', - * so as imported it takes and returns an untyped value. - */ -export const tlsConnect: ( - options: ConnectionOptions, - secureConnectListener?: () => void, -) => TLSSocket = connect - -// This, and the `satisfies ConnectionOptions` in parent-proxy.ts, check -// something only while ConnectionOptions is itself a real type. Should it -// become untyped too, the directive goes unused and the type check fails. -// @ts-expect-error -- 'servernme' is not an option -void ({ servernme: 'proxy.example' } satisfies ConnectionOptions) diff --git a/test/proxy-cli.test.ts b/test/proxy-cli.test.ts index efef05896..6f18e582b 100644 --- a/test/proxy-cli.test.ts +++ b/test/proxy-cli.test.ts @@ -3,7 +3,7 @@ import { spawn, spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { connect, createServer, type Socket } from 'node:net' import type { Writable } from 'node:stream' -import { tlsConnect } from './helpers/tls-connect.js' +import { connect as tlsConnect } from 'node:tls' import { encodeFrame } from '../src/sandbox/decider-client.js' import { generateCa } from '../src/sandbox/mitm-ca.js' import { SERVES_EMITTED_CONNECTIONS } from './helpers/emitted-connections.js' diff --git a/test/sandbox/client-abort.test.ts b/test/sandbox/client-abort.test.ts index 2c1f76898..eedd9c128 100644 --- a/test/sandbox/client-abort.test.ts +++ b/test/sandbox/client-abort.test.ts @@ -8,8 +8,7 @@ import { connect } from 'node:net' import type { AddressInfo } from 'node:net' import { readFileSync } from 'node:fs' import { join } from 'node:path' -import type { TLSSocket } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect, type TLSSocket } from 'node:tls' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, disposeMitmCA } from '../../src/sandbox/mitm-ca.js' diff --git a/test/sandbox/client-hello-sni.test.ts b/test/sandbox/client-hello-sni.test.ts index 79b8b5c2b..7d87a857a 100644 --- a/test/sandbox/client-hello-sni.test.ts +++ b/test/sandbox/client-hello-sni.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test' import { createServer, type AddressInfo } from 'node:net' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect } from 'node:tls' import { serverNameFromClientHello } from '../../src/sandbox/tls-terminate-proxy.js' const u16 = (n: number): Buffer => Buffer.from([n >> 8, n & 0xff]) diff --git a/test/sandbox/credential-mask-sigv4.test.ts b/test/sandbox/credential-mask-sigv4.test.ts index b82e176c1..357726f17 100644 --- a/test/sandbox/credential-mask-sigv4.test.ts +++ b/test/sandbox/credential-mask-sigv4.test.ts @@ -21,7 +21,7 @@ import type { IncomingHttpHeaders, IncomingMessage } from 'node:http' import type { Server, AddressInfo } from 'node:net' import { spawn } from 'node:child_process' import { connect as netConnect } from 'node:net' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect } from 'node:tls' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/test/sandbox/deny-late-body.test.ts b/test/sandbox/deny-late-body.test.ts index f40a31d81..12630fc8a 100644 --- a/test/sandbox/deny-late-body.test.ts +++ b/test/sandbox/deny-late-body.test.ts @@ -3,7 +3,7 @@ import { afterEach, expect, test } from 'bun:test' // skipped where this runtime cannot terminate it in-process. import { describeWithTls as describe } from '../helpers/emitted-connections.js' import { connect, type AddressInfo } from 'node:net' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect } from 'node:tls' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' diff --git a/test/sandbox/invalid-upstream-response.test.ts b/test/sandbox/invalid-upstream-response.test.ts index ee9540120..fae20b5de 100644 --- a/test/sandbox/invalid-upstream-response.test.ts +++ b/test/sandbox/invalid-upstream-response.test.ts @@ -10,8 +10,10 @@ import { } from 'node:http' import { createServer, connect, type AddressInfo, type Server } from 'node:net' import type { LookupFunction, Socket } from 'node:net' -import { createServer as createTlsServer } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { + createServer as createTlsServer, + connect as tlsConnect, +} from 'node:tls' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' diff --git a/test/sandbox/proxy-only-edges.test.ts b/test/sandbox/proxy-only-edges.test.ts index 9dd1654b2..1a94fd992 100644 --- a/test/sandbox/proxy-only-edges.test.ts +++ b/test/sandbox/proxy-only-edges.test.ts @@ -3,8 +3,7 @@ import { closeSync, openSync } from 'node:fs' import { createServer, type Server } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' -import type { TLSSocket } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect, type TLSSocket } from 'node:tls' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' import { openFdStreams, startProxyOnly } from '../../src/sandbox/proxy-only.js' diff --git a/test/sandbox/proxy-only.test.ts b/test/sandbox/proxy-only.test.ts index fc43ea155..9bcaa4231 100644 --- a/test/sandbox/proxy-only.test.ts +++ b/test/sandbox/proxy-only.test.ts @@ -14,7 +14,7 @@ import { type Socket, } from 'node:net' import { PassThrough } from 'node:stream' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect } from 'node:tls' import { createServer as createHttpServer, Server as HttpServer, diff --git a/test/sandbox/request-rewriting.test.ts b/test/sandbox/request-rewriting.test.ts index 48865f2d7..f890ef73a 100644 --- a/test/sandbox/request-rewriting.test.ts +++ b/test/sandbox/request-rewriting.test.ts @@ -11,7 +11,7 @@ import { } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Server } from 'node:net' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect } from 'node:tls' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { diff --git a/test/sandbox/request-target-forwarding.test.ts b/test/sandbox/request-target-forwarding.test.ts index 84b52a7c4..a6987c88e 100644 --- a/test/sandbox/request-target-forwarding.test.ts +++ b/test/sandbox/request-target-forwarding.test.ts @@ -4,8 +4,10 @@ import { test, expect, beforeAll, afterAll, spyOn } from 'bun:test' import { describeWithTls as describe } from '../helpers/emitted-connections.js' import { connect, createServer, type AddressInfo, type Server } from 'node:net' import type { LookupFunction, Socket } from 'node:net' -import { createServer as createTlsServer } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { + connect as tlsConnect, + createServer as createTlsServer, +} from 'node:tls' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/test/sandbox/response-header-bytes.test.ts b/test/sandbox/response-header-bytes.test.ts index a89dec407..2f8ac4200 100644 --- a/test/sandbox/response-header-bytes.test.ts +++ b/test/sandbox/response-header-bytes.test.ts @@ -7,8 +7,11 @@ import { type Server, type Socket, } from 'node:net' -import { createServer as createTlsServer, type TLSSocket } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { + connect as tlsConnect, + createServer as createTlsServer, + type TLSSocket, +} from 'node:tls' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' diff --git a/test/sandbox/tls-terminate-destroy-soon.test.ts b/test/sandbox/tls-terminate-destroy-soon.test.ts index cde7072f6..632a888a6 100644 --- a/test/sandbox/tls-terminate-destroy-soon.test.ts +++ b/test/sandbox/tls-terminate-destroy-soon.test.ts @@ -2,8 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' import { createServer, type AddressInfo } from 'node:net' import { join } from 'node:path' -import type { TLSSocket } from 'node:tls' -import { tlsConnect as connect } from '../helpers/tls-connect.js' +import { connect, type TLSSocket } from 'node:tls' import { createMitmCA } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' import { serverTlsOn } from '../../src/sandbox/tls-terminate-proxy.js' diff --git a/test/sandbox/tls-terminate-handshake-deadline.test.ts b/test/sandbox/tls-terminate-handshake-deadline.test.ts index 0e018005d..4a910ef57 100644 --- a/test/sandbox/tls-terminate-handshake-deadline.test.ts +++ b/test/sandbox/tls-terminate-handshake-deadline.test.ts @@ -2,8 +2,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' import { Duplex } from 'node:stream' -import type { SecureVersion } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect, type SecureVersion } from 'node:tls' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' diff --git a/test/sandbox/tls-terminate-in-process.test.ts b/test/sandbox/tls-terminate-in-process.test.ts index 58defaca0..7dc87195e 100644 --- a/test/sandbox/tls-terminate-in-process.test.ts +++ b/test/sandbox/tls-terminate-in-process.test.ts @@ -16,8 +16,7 @@ import { type AddressInfo, type Socket, } from 'node:net' -import type { TLSSocket } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect, type TLSSocket } from 'node:tls' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' diff --git a/test/sandbox/tls-terminate-keepalive.test.ts b/test/sandbox/tls-terminate-keepalive.test.ts index 392eaf203..75cbb0636 100644 --- a/test/sandbox/tls-terminate-keepalive.test.ts +++ b/test/sandbox/tls-terminate-keepalive.test.ts @@ -5,8 +5,7 @@ import { describeWithTls as describe } from '../helpers/emitted-connections.js' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Server, type Socket } from 'node:net' import type { LookupFunction } from 'node:net' -import type { TLSSocket } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect, type TLSSocket } from 'node:tls' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' diff --git a/test/sandbox/tls-terminate-relay-budget.test.ts b/test/sandbox/tls-terminate-relay-budget.test.ts index db806bb4f..cafb96ff9 100644 --- a/test/sandbox/tls-terminate-relay-budget.test.ts +++ b/test/sandbox/tls-terminate-relay-budget.test.ts @@ -6,7 +6,7 @@ import { createHash } from 'node:crypto' import { request } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect } from 'node:tls' import { createByteBudget } from '../../src/sandbox/emitted-connection.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' diff --git a/test/sandbox/tls-terminate-upload-gate.test.ts b/test/sandbox/tls-terminate-upload-gate.test.ts index bb2dc261b..7cadff723 100644 --- a/test/sandbox/tls-terminate-upload-gate.test.ts +++ b/test/sandbox/tls-terminate-upload-gate.test.ts @@ -6,8 +6,11 @@ import { type AddressInfo, type Socket, } from 'node:net' -import { createServer as createTlsServer, type TLSSocket } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { + connect as tlsConnect, + createServer as createTlsServer, + type TLSSocket, +} from 'node:tls' import { createByteBudget } from '../../src/sandbox/emitted-connection.js' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' diff --git a/test/sandbox/trailers-then-request.test.ts b/test/sandbox/trailers-then-request.test.ts index 211f97b7c..3f7586717 100644 --- a/test/sandbox/trailers-then-request.test.ts +++ b/test/sandbox/trailers-then-request.test.ts @@ -7,8 +7,7 @@ import { } from 'node:http' import { createServer as createHttpsServer } from 'node:https' import { connect, type AddressInfo, type Socket } from 'node:net' -import type { TLSSocket } from 'node:tls' -import { tlsConnect } from '../helpers/tls-connect.js' +import { connect as tlsConnect, type TLSSocket } from 'node:tls' import { createHttpProxyServer } from '../../src/sandbox/http-proxy.js' import { createMitmCA, generateCa } from '../../src/sandbox/mitm-ca.js' import { mintLeafCert } from '../../src/sandbox/mitm-leaf.js' From adbae3308aae5c4f65f76303a6a0ec9c161eaafe Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:33:46 +0000 Subject: [PATCH 13/15] ci: a simplification pass over the new legs - The smoke test is a suite like any other, test/smoke/bundled.test.ts, as tls-terminate-tunnel-ends already is for its fixture. The runner, the `test:bundled` script and the CI step go, and the second full run on Linux (bubblewrap 0.12.0, ripgrep 15.2.0) now covers it too. - It needed a tsconfig, an eslint block and an exclude of its own for one reason: the type check and lint ran before dist/ was built. `npm run typecheck` now builds src instead of only checking it, and lint follows it. All three go. - embedder.ts: one configuration literal instead of two, paths relative to the working directory instead of quoted, the multicall part in fourteen lines, one header instead of two that overlapped. Every check stays. - The Ubuntu container takes the runner's bun, bound in like node. That leaves Alpine as the only install from an archive, so the script is inline there. - The Alpine step uses the workspace as the step before left it. What is in node_modules and dist is JavaScript, so the copy, the second `npm ci` and the second build go, about 70 s a leg. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- .github/scripts/install-bun.sh | 24 --- .github/workflows/integration-tests.yml | 84 ++++----- eslint.config.js | 11 -- package.json | 3 +- test/smoke/bundled.test.ts | 80 ++++++++ test/smoke/embedder.ts | 231 +++++++----------------- test/smoke/run-bundled.ts | 124 ------------- test/smoke/tsconfig.json | 8 - tsconfig.test.json | 3 +- 9 files changed, 192 insertions(+), 376 deletions(-) delete mode 100755 .github/scripts/install-bun.sh create mode 100644 test/smoke/bundled.test.ts delete mode 100644 test/smoke/run-bundled.ts delete mode 100644 test/smoke/tsconfig.json diff --git a/.github/scripts/install-bun.sh b/.github/scripts/install-bun.sh deleted file mode 100755 index 09e99be65..000000000 --- a/.github/scripts/install-bun.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh -# install-bun.sh [-musl] -# -# For a container: puts bun in /usr/local/bin, at the version the rest of the -# matrix runs (oven-sh/setup-bun). The tree carries a dozen workarounds for -# one bun version or another, so "whatever bun shipped this morning" is not -# the runtime under test. The release archive, by checksum, rather than an -# installer piped into a shell. -set -eu - -case "$(uname -m)" in - x86_64) zip=bun-linux-x64${3-} sha256=$1 ;; - aarch64) zip=bun-linux-aarch64${3-} sha256=$2 ;; - *) - echo "no bun archive is pinned for $(uname -m)" - exit 1 - ;; -esac - -curl -fsSL --retry 3 -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/$zip.zip" -echo "$sha256 /tmp/bun.zip" | sha256sum -c - -unzip -q /tmp/bun.zip -d /tmp -install -m 0755 "/tmp/$zip/bun" /usr/local/bin/bun -bun --version diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 1df33103d..7058174b8 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -242,12 +242,17 @@ jobs: run: npm ci # `npm run build` compiles src only. This is the one step that checks - # the types of test/, which bun strips at runtime, save test/smoke, - # which imports the built package: `npm run test:bundled` checks that. + # the types of test/, which bun strips at runtime. - name: Typecheck if: matrix.os != 'windows' run: npm run typecheck + # After the type check, which builds dist/: test/smoke imports it, and + # without it the rules that read types see `any` there and say nothing. + - name: Lint (ESLint) + if: matrix.os == 'linux' && matrix.arch == 'x86-64' + run: npx eslint . + - name: Build seccomp binaries (Linux) if: matrix.os == 'linux' run: npm run build:seccomp @@ -260,25 +265,10 @@ jobs: if: matrix.os != 'windows' run: npm run build - # After the build: without dist/ the rules that read types see `any` - # throughout test/smoke, and report nothing there. - - name: Lint (ESLint) - if: matrix.os == 'linux' && matrix.arch == 'x86-64' - run: npx eslint . - - name: Run tests if: matrix.os != 'windows' run: npm test - # The suites run the sources unbundled. An application that embeds the - # library ships it minified and compiled to bytecode in one file, where - # a function's name, its source text and the files beside it are all - # gone. This compiles a small program that imports the built package - # that way and has it wrap real commands. - - name: Run the package bundled, minified and as bytecode - if: matrix.os != 'windows' - run: npm run test:bundled - # bubblewrap 0.12 refuses a mount on a symlink destination, which the # distribution's packaged bubblewrap accepts; run the suite against it # as well so a profile that mounts on a link is caught here, not on a @@ -387,6 +377,11 @@ jobs: with: node-version: '22' + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.4.2 + - name: Enable unprivileged user namespaces on host run: | sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true @@ -405,20 +400,22 @@ jobs: --security-opt apparmor=unconfined \ -v "${{ github.workspace }}:/work" \ -v "$(dirname "$(dirname "$(command -v node)")")":/opt/node:ro \ + -v "$(command -v bun)":/usr/local/bin/bun:ro \ -w /work \ -e SRT_E2E_DOCKER=1 \ ubuntu:24.04 \ bash -euo pipefail -c ' apt-get update -qq - # node and npm are those of the runner, bound in, and not those - # of the image, which has Node 18, below what engines in - # package.json allows. - apt-get install -y -qq bubblewrap socat ripgrep python3 curl ca-certificates unzip gcc libseccomp-dev libcap2-bin + # node, npm and bun are those of the runner, bound in: no piped + # installer, not the Node 18 of the image, which is below what + # engines in package.json allows, and the bun the rest of the + # matrix runs. The tree carries a dozen bun-version-specific + # workarounds, so "whatever bun shipped this morning" is not + # the runtime under test. + apt-get install -y -qq bubblewrap socat ripgrep python3 ca-certificates gcc libseccomp-dev libcap2-bin export PATH="/opt/node/bin:$PATH" node --version - .github/scripts/install-bun.sh \ - 36368faef7527875d5ffa52e53cd48021741f2a83eb6208a8dd64068d422a913 \ - 54328bbc2d9c8e0c9f892c544d66c57a83b84139e34909e5ee81758f1ac8fda7 + bun --version npm ci npm run build:seccomp npm run build @@ -428,35 +425,34 @@ jobs: capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" ' - # The same two runs on musl: bubblewrap, socat and ripgrep are Alpine's - # own builds, and bun is its musl build. The seccomp helper is the one - # the step above built, static against glibc, because that is the file - # the npm package brings to a musl host. The rest of the checkout is - # copied without what that step installed for glibc. + # The same two runs on musl, and the package as an embedder ships it. + # bubblewrap, socat, ripgrep and node are Alpine's own builds, and bun is + # its musl build, from the release archive by checksum. The rest is what + # the step above left in the workspace: node_modules and dist, which are + # JavaScript, and the seccomp helper, static against glibc, because that + # is the file the npm package brings to a musl host. - name: Run srt end-to-end as uid 0 in an Alpine container run: | docker run --rm \ --cap-add SYS_ADMIN \ --security-opt seccomp=unconfined \ --security-opt apparmor=unconfined \ - -v "${{ github.workspace }}:/src:ro" \ + -v "${{ github.workspace }}:/work" \ + -w /work \ -e SRT_E2E_DOCKER=1 \ alpine:3.22 \ - sh -euo pipefail -c ' - apk add --no-cache bash bubblewrap socat ripgrep python3 curl ca-certificates unzip libcap-utils libstdc++ libgcc nodejs npm tar - mkdir /work - tar -C /src --exclude=./node_modules --exclude=./dist --exclude=./.git -cf - . | tar -C /work -xf - - cd /work - .github/scripts/install-bun.sh \ - 4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742 \ - 71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df \ - -musl - npm ci - npm run build + sh -euc ' + apk add --no-cache bash bubblewrap socat ripgrep python3 curl ca-certificates unzip libcap-utils libstdc++ libgcc nodejs + case "$(uname -m)" in + x86_64) bun=bun-linux-x64-musl sha256=4835eca59d6da70f4674f5642f6e459dcadab773695b2ed9922d131057989742 ;; + aarch64) bun=bun-linux-aarch64-musl sha256=71760b6c8ea30623b81a4907cb815d48e2ea266f2e73e751534a44a0607950df ;; + esac + curl -fsSL --retry 3 -o /tmp/bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/$bun.zip" + echo "$sha256 /tmp/bun.zip" | sha256sum -c - + unzip -q /tmp/bun.zip -d /tmp + install -m 0755 "/tmp/$bun/bun" /usr/local/bin/bun echo "== as uid 0 with CAP_SYS_ADMIN" - bun test test/docker-weak-sandbox.test.ts + bun test test/docker-weak-sandbox.test.ts test/smoke/bundled.test.ts echo "== as uid 0 without CAP_SYS_ADMIN" capsh --drop=cap_sys_admin -- -c "bun test test/docker-weak-sandbox.test.ts" - echo "== bundled, minified and as bytecode" - npm run test:bundled ' diff --git a/eslint.config.js b/eslint.config.js index d76ba18d6..821860cec 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -42,17 +42,6 @@ export default [ }, }, }, - { - // Imports the BUILT package, so it is typechecked after the build and by - // a project of its own (see `npm run test:bundled`). - files: ['test/smoke/**/*.ts'], - languageOptions: { - parserOptions: { - project: './test/smoke/tsconfig.json', - projectService: false, - }, - }, - }, { plugins: { 'eslint-plugin-n': pluginNode, diff --git a/package.json b/package.json index ec6d523dc..4e3dfbbe4 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,7 @@ "clean": "rm -rf dist", "test": "bun test", "test:node": "node test/utils/which-node-test.mjs && bun test test/control-fd.test.ts test/utils/real-path.test.ts test/sandbox/default-write-paths.test.ts test/sandbox/rules-through-links-on-every-runtime.test.ts test/sandbox/tls-terminate-tunnel-ends.test.ts", - "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", - "test:bundled": "tsc --noEmit -p test/smoke/tsconfig.json && bun test/smoke/run-bundled.ts", + "typecheck": "tsc && tsc --noEmit -p tsconfig.test.json", "lint": "eslint 'src/**/*.ts' --fix --cache --cache-location=node_modules/.cache/.eslintcache", "lint:check": "eslint 'src/**/*.ts' --cache --cache-location=node_modules/.cache/.eslintcache", "format": "prettier --write 'src/**/*.ts' --cache --log-level warn", diff --git a/test/smoke/bundled.test.ts b/test/smoke/bundled.test.ts new file mode 100644 index 000000000..3d78f3491 --- /dev/null +++ b/test/smoke/bundled.test.ts @@ -0,0 +1,80 @@ +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { spawnSync } from 'node:child_process' +import { copyFileSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { isLinux, isWindows } from '../helpers/platform.js' + +/** + * Compiles embedder.ts, which says why, the ways an embedding application is + * built, and runs each result from an empty directory, so that nothing of the + * package is on disk around it. Not quite nothing: a bundle records where its + * modules were when it was built, so on the machine that built it a lookup + * from the library's own location still reaches this checkout. + */ +describe.skipIf(isWindows).each([ + { form: 'one file', flags: [] }, + // Modules are evaluated in another order, the library in a chunk of its own. + { form: 'split ESM chunks', flags: ['--splitting', '--format=esm'] }, +])('the built package, minified and as bytecode in $form', ({ flags }) => { + let home: string + + beforeAll(() => { + home = realpathSync(mkdtempSync(join(tmpdir(), 'srt-embedder-'))) + const build = spawnSync( + process.execPath, + [ + ...['build', '--compile', '--minify', '--bytecode', ...flags], + join(import.meta.dir, 'embedder.ts'), + ...['--outfile', join(home, 'embedder')], + ], + { encoding: 'utf8' }, + ) + expect( + `status ${build.status}\n${build.stdout}${build.stderr}`, + ).toStartWith('status 0\n') + if (isLinux) { + copyFileSync( + join( + import.meta.dir, + '../../vendor/seccomp', + process.arch, + 'apply-seccomp', + ), + join(home, 'apply-seccomp'), + ) + } + }, 120_000) + + afterAll(() => rmSync(home, { recursive: true, force: true })) + + test.each([ + { how: 'told where the seccomp helper is', multicall: undefined }, + ...(isLinux + ? [ + { + how: 'reaching the helper and ripgrep through argv0', + multicall: '1', + }, + ] + : []), + ])( + 'wraps a command, $how', + ({ multicall }) => { + const run = spawnSync(join(home, 'embedder'), [], { + cwd: home, + env: { + ...process.env, + SRT_SMOKE_APPLY_SECCOMP: join(home, 'apply-seccomp'), + SRT_SMOKE_MULTICALL: multicall, + }, + encoding: 'utf8', + }) + expect(`${run.stdout}${run.stderr}${String(run.error ?? '')}`).toEndWith( + 'SMOKE OK\n', + ) + expect(run.status).toBe(0) + }, + 120_000, + ) +}) diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts index 5af85207d..408cd0c85 100644 --- a/test/smoke/embedder.ts +++ b/test/smoke/embedder.ts @@ -1,36 +1,19 @@ /** - * What an embedder ships, in miniature. `npm run test:bundled` compiles this - * program and the BUILT package it imports (dist/, not src/) into one minified - * file of bytecode, the way an application that embeds the library is built, - * and runs the result. The suites run the sources unbundled, so nothing else - * would notice code that leans on a function's name, on its own source text, - * or on a file that sits beside it on disk. + * What an embedder ships, in miniature. bundled.test.ts compiles this and the + * BUILT package (dist/) into one minified executable of bytecode, where a + * function's name, its source text and the files beside it are all gone. It + * wraps a real command, checks what that did on the host, and checks that a + * refusal keeps the name and the code a caller branches on. The library comes + * through `import()`, so that a split bundle puts it in a chunk of its own. * - * It wraps real commands and checks what they did on the host, and that a - * refusal still carries the name and the code a caller branches on once every - * identifier has been shortened. The library is reached through `import()`, so - * that where the bundle is split into chunks it sits in one of its own. - * - * A compiled program has no package directory around it: no `vendor/`, no - * `node_modules`, no `package.json`, and `import.meta.url` names nothing on - * disk. The runner (run-bundled.ts) runs it from a directory of its own for - * that reason, and names the seccomp helper to it in SRT_SMOKE_APPLY_SECCOMP, - * as an embedder names its own copy to the library. - * - * With SRT_SMOKE_MULTICALL=1 it goes one step further, to the arrangement of - * an embedder that has the helper and ripgrep INSIDE its own executable and - * reaches them by running itself under another name: `seccomp.argv0` with - * `applyPath: '/proc/self/fd/3'`, fd 3 being an open handle on the executable - * that every wrapped command inherits, and `ripgrep.argv0` with the - * executable as the command. This program plays that executable: started as - * `apply-seccomp` or as `rg`, it runs the real one (see runAsAnotherProgram). - * No file path of a helper is given to the library then, and none may be - * looked for. + * SRT_SMOKE_APPLY_SECCOMP names the seccomp helper. With SRT_SMOKE_MULTICALL=1 + * the helper and ripgrep are reached as by an embedder that carries both: by + * starting this executable under their names (`seccomp.argv0` with fd 3 open + * on it, and `ripgrep.argv0`). */ import { spawnSync } from 'node:child_process' import { appendFileSync, - closeSync, existsSync, mkdirSync, mkdtempSync, @@ -44,59 +27,26 @@ import { createServer, type AddressInfo } from 'node:net' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' -/** - * What a multicall executable does first: if it was started under the name of - * a program it carries, be that program. The library names the helper through - * the ARGV0 variable, since nothing between it and the exec inside the sandbox - * can set argv[0], and ripgrep through argv[0] itself. - */ -function runAsAnotherProgram(): void { - const name = - process.env.ARGV0 === 'apply-seccomp' - ? process.env.ARGV0 - : basename(process.argv0) - const real = - name === 'apply-seccomp' - ? process.env.SRT_SMOKE_APPLY_SECCOMP - : name === 'rg' - ? process.env.SRT_SMOKE_RG - : undefined - if (name !== 'apply-seccomp' && name !== 'rg') return - if (real === undefined) { - console.error(`started as ${name}, and no ${name} to run was named`) - process.exit(127) - } - const log = process.env.SRT_SMOKE_MULTICALL_LOG - if (log !== undefined) { - try { - appendFileSync(log, `${name}\n`) - } catch { - // Not writable from where this was started: the run is what matters. - } - } - // The name is this process's, not what it runs. - const env = { ...process.env } - delete env.ARGV0 - const ran = spawnSync(real, process.argv.slice(2), { stdio: 'inherit', env }) - if (ran.error !== undefined) { - console.error(`${name}: ${String(ran.error)}`) - process.exit(127) - } - process.exit(ran.status ?? 128) +// The multicall part. The library names the helper in ARGV0, since nothing +// between it and the exec inside the sandbox can set argv[0]. +const { ARGV0, ...env } = process.env +const name = ARGV0 === 'apply-seccomp' ? ARGV0 : basename(process.argv0) +if (name === 'apply-seccomp' || name === 'rg') { + appendFileSync(env.SRT_SMOKE_MULTICALL_LOG ?? '/dev/null', `${name}\n`) + const ran = spawnSync( + name === 'rg' ? name : (env.SRT_SMOKE_APPLY_SECCOMP ?? name), + process.argv.slice(2), + { stdio: 'inherit', env }, + ) + if (ran.error !== undefined) console.error(`${name}: ${String(ran.error)}`) + process.exit(ran.status ?? 127) } -runAsAnotherProgram() function check(what: string, ok: boolean, detail = ''): void { - if (!ok) - throw new Error(`FAILED: ${what}${detail === '' ? '' : `\n${detail}`}`) + if (!ok) throw new Error(`FAILED: ${what}\n${detail}`) console.log(`ok: ${what}`) } -/** One word of a shell command. */ -function quoted(word: string): string { - return `'${word.replace(/'/g, `'\\''`)}'` -} - // Bytecode is CommonJS, which has no top-level await. async function main(): Promise { const { SandboxManager, LinuxSandboxProfileError } = await import( @@ -104,64 +54,46 @@ async function main(): Promise { ) const multicall = process.env.SRT_SMOKE_MULTICALL === '1' const applyPath = process.env.SRT_SMOKE_APPLY_SECCOMP - // Inside a container the host masks /proc, and a sandbox nested in one - // has to be the weaker kind the library has a setting for; the container - // job says so the way it says it to the suite it runs. - const nested = - process.env.SRT_E2E_DOCKER === '1' - ? { enableWeakerNestedSandbox: true } - : {} const root = realpathSync(mkdtempSync(join(tmpdir(), 'srt-bundled-smoke-'))) const work = join(root, 'work') const outside = join(root, 'outside') mkdirSync(work) mkdirSync(outside) - - // How the library is told to reach the helper and ripgrep. Carried inside - // this executable: fd 3 of every wrapped command is a handle on it, and the - // shim above writes a line where a wrapped command can write, each time it - // is started as one of them. + // Where a wrapped command can write: the helper is started inside. const multicallLog = join(work, 'multicall.log') - const closing: Array<() => void> = [] - const self = multicall ? openSync(process.execPath, 'r') : undefined - if (self !== undefined) closing.push(() => closeSync(self)) if (multicall) process.env.SRT_SMOKE_MULTICALL_LOG = multicallLog - const tools = multicall - ? { - seccomp: { applyPath: '/proc/self/fd/3', argv0: 'apply-seccomp' }, - ripgrep: { command: process.execPath, args: [], argv0: 'rg' }, - } - : applyPath === undefined - ? {} - : { seccomp: { applyPath } } - const helperExpected = process.platform === 'linux' && 'seccomp' in tools + + const config = { + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { + denyRead: [], + allowWrite: [work], + denyWrite: [join(work, 'denied.txt')], + }, + // The container job says it is one the way it says it to its suite. + enableWeakerNestedSandbox: process.env.SRT_E2E_DOCKER === '1', + ...(multicall + ? { + seccomp: { applyPath: '/proc/self/fd/3', argv0: 'apply-seccomp' }, + ripgrep: { command: process.execPath, args: [], argv0: 'rg' }, + } + : { seccomp: { applyPath } }), + } try { - await SandboxManager.initialize({ - network: { allowedDomains: [], deniedDomains: [] }, - filesystem: { - denyRead: [], - allowWrite: [work], - denyWrite: [join(work, 'denied.txt')], - }, - ...tools, - ...nested, - }) + await SandboxManager.initialize(config) - // After initialize, so that the helper the configuration names is the - // one that is looked for. A helper that is not found is a warning: - // initialize() has thrown already for what would be an error. + // After initialize, so that the configured helper is the one looked for. const { warnings } = SandboxManager.checkDependencies() check( 'the dependency check finds the seccomp helper', - !helperExpected || !warnings.some(w => w.includes('seccomp')), + !warnings.some(w => w.includes('seccomp')), warnings.join('\n'), ) // A listener on the host, which the same words reach from outside the // sandbox: without that, "blocked" could mean a shell that cannot connect. - const listener = createServer().listen(0, '127.0.0.1') - closing.push(() => listener.close()) + const listener = createServer().listen(0, '127.0.0.1').unref() await once(listener, 'listening') const { port } = listener.address() as AddressInfo const connect = `(exec 9<>/dev/tcp/127.0.0.1/${port}) 2>/dev/null && echo NET-OPEN || echo NET-BLOCKED` @@ -174,9 +106,9 @@ async function main(): Promise { const wrapped = await SandboxManager.wrapWithSandbox( [ 'echo BOOTED', - `echo allowed > ${quoted(join(work, 'allowed.txt'))}`, - `echo denied > ${quoted(join(work, 'denied.txt'))} 2>/dev/null || echo WRITE-DENIED`, - `echo outside > ${quoted(join(outside, 'x.txt'))} 2>/dev/null || echo OUTSIDE-DENIED`, + 'echo allowed > allowed.txt', + 'echo denied > denied.txt 2>/dev/null || echo WRITE-DENIED', + 'echo outside > ../outside/x.txt 2>/dev/null || echo OUTSIDE-DENIED', connect, 'grep -q "^Seccomp:[[:space:]]*2" /proc/self/status 2>/dev/null && echo SECCOMP-ON || echo SECCOMP-OFF', 'exit 7', @@ -187,9 +119,9 @@ async function main(): Promise { encoding: 'utf8', cwd: work, timeout: 60000, - // fd 3 of the command is this executable, where that is how the helper - // is reached. - stdio: self === undefined ? 'pipe' : ['ignore', 'pipe', 'pipe', self], + stdio: multicall + ? ['ignore', 'pipe', 'pipe', openSync(process.execPath, 'r')] + : 'pipe', }) const output = `${result.stdout}${result.stderr}` check('the wrapped command started', output.includes('BOOTED'), output) @@ -216,26 +148,17 @@ async function main(): Promise { output.includes('NET-BLOCKED') && !output.includes('NET-OPEN'), output, ) - if (helperExpected) { - check( - 'the command ran under the seccomp filter', - output.includes('SECCOMP-ON'), - output, - ) - } - if (multicall) { - const started = existsSync(multicallLog) - ? readFileSync(multicallLog, 'utf8').split('\n') - : [] - check( - 'the scan ran ripgrep by starting this executable as rg', - started.includes('rg'), - started.join(','), - ) + check( + 'the command ran under the seccomp filter', + process.platform !== 'linux' || output.includes('SECCOMP-ON'), + output, + ) + for (const program of multicall ? ['rg', 'apply-seccomp'] : []) { + const started = readFileSync(multicallLog, 'utf8') check( - 'the command reached the helper by starting this executable as apply-seccomp, through fd 3', - started.includes('apply-seccomp'), - started.join(','), + `this executable was started as ${program}`, + started.split('\n').includes(program), + started, ) } check( @@ -249,50 +172,36 @@ async function main(): Promise { 'the mount point for the denied path is gone after the command', !existsSync(join(work, 'denied.txt')), ) - - const config = SandboxManager.getConfig() check( 'the configuration reads back', - config?.filesystem.allowWrite.includes(work) === true, + SandboxManager.getConfig()?.filesystem.allowWrite.includes(work) === true, ) if (process.platform === 'linux') { // More absent deny paths than bubblewrap takes arguments for: the wrap // is refused before anything is spawned or written. SandboxManager.updateConfig({ - network: { allowedDomains: [], deniedDomains: [] }, + ...config, filesystem: { - denyRead: [], - allowWrite: [work], + ...config.filesystem, denyWrite: Array.from({ length: 4000 }, (_, i) => join(work, `absent-${i}`), ), }, - ...tools, - ...nested, }) const refusal: unknown = await SandboxManager.wrapWithSandbox( 'true', - ).then( - () => undefined, - (error: unknown) => error, - ) + ).catch((error: unknown) => error) check( - 'a profile that does not fit is refused with a typed error', - refusal instanceof LinuxSandboxProfileError, + 'a profile that does not fit is refused with an error that keeps its type, name and code', + refusal instanceof LinuxSandboxProfileError && + refusal.name === 'LinuxSandboxProfileError' && + refusal.code === 'too_many_arguments', String(refusal), ) - const typed = refusal as InstanceType - check( - 'which keeps its name and its code with every identifier shortened', - typed.name === 'LinuxSandboxProfileError' && - typed.code === 'too_many_arguments', - `name ${typed.name}, code ${String(typed.code)}`, - ) } console.log('SMOKE OK') } finally { - for (const close of closing) close() await SandboxManager.reset() rmSync(root, { recursive: true, force: true }) } diff --git a/test/smoke/run-bundled.ts b/test/smoke/run-bundled.ts deleted file mode 100644 index ad595a897..000000000 --- a/test/smoke/run-bundled.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Builds test/smoke/embedder.ts the ways an embedding application is built and - * runs each result: `npm run test:bundled`, after `npm run build`. - * - * Two forms, both minified with identifiers shortened and compiled to bytecode - * in one executable: one flat file, and code-split ESM chunks, where modules - * are evaluated in another order and the library sits in a chunk of its own. - * - * Each executable is copied, with the seccomp helper this checkout built, into - * an empty directory and run from there. Nothing of the package is on disk - * around it then, which is how an embedded copy runs: a lookup that starts - * from the library's own location finds nothing, and has to be one the - * configuration already answered. (Not quite nothing here: a bundle records - * where its modules were when it was built, so on the machine that built it - * such a lookup still reaches this checkout.) - * - * On Linux each is run twice: told where the helper file is, and as a - * multicall executable that carries the helper and ripgrep itself and is - * started under their names (see embedder.ts), which is how an application - * that has both compiled in reaches them. - */ -import { spawnSync } from 'node:child_process' -import { - chmodSync, - copyFileSync, - mkdtempSync, - realpathSync, - rmSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' - -const repo = process.cwd() -const entry = join(repo, 'test', 'smoke', 'embedder.ts') - -const FORMS: Array<{ name: string; flags: string[] }> = [ - { name: 'one file', flags: [] }, - { name: 'split ESM chunks', flags: ['--splitting', '--format=esm'] }, -] - -const builtHelper = join( - repo, - 'vendor', - 'seccomp', - process.arch === 'arm64' ? 'arm64' : 'x64', - 'apply-seccomp', -) - -let failed = false -for (const form of FORMS) { - const home = realpathSync(mkdtempSync(join(tmpdir(), 'srt-embedder-'))) - try { - const program = join(home, 'embedder') - const build = spawnSync( - process.execPath, - [ - 'build', - '--compile', - '--minify', - '--bytecode', - ...form.flags, - entry, - '--outfile', - program, - ], - { cwd: repo, encoding: 'utf8' }, - ) - if (build.status !== 0) { - console.error( - `== ${form.name}: did not build\n${build.stdout}${build.stderr}`, - ) - failed = true - continue - } - - // Less what the program itself reads, should the caller's shell hold it. - const env = Object.fromEntries( - Object.entries(process.env).filter( - ([name]) => name !== 'ARGV0' && !name.startsWith('SRT_SMOKE_'), - ), - ) - const modes: Array<{ name: string; env: typeof env }> = [ - { name: form.name, env }, - ] - if (process.platform === 'linux') { - // Both are there on every Linux leg. Were one not, copyFileSync or the - // program would fail: a run that skipped these modes would prove less - // than it says. - const helper = join(home, 'apply-seccomp') - copyFileSync(builtHelper, helper) - chmodSync(helper, 0o755) - env.SRT_SMOKE_APPLY_SECCOMP = helper - modes.push({ - name: `${form.name}, helper and ripgrep reached through argv0`, - env: { - ...env, - SRT_SMOKE_MULTICALL: '1', - SRT_SMOKE_RG: Bun.which('rg') ?? undefined, - }, - }) - } - for (const mode of modes) { - console.log(`== ${mode.name}`) - const run = spawnSync(program, [], { - cwd: home, - env: mode.env, - encoding: 'utf8', - timeout: 300000, - }) - // Both are null for a program that could not be started. - process.stdout.write(run.stdout ?? '') - process.stderr.write(run.stderr ?? '') - if (run.status !== 0 || !run.stdout.includes('SMOKE OK')) { - console.error( - `== ${mode.name}: FAILED (status ${String(run.status)}${run.error === undefined ? '' : `, ${String(run.error)}`})`, - ) - failed = true - } - } - } finally { - rmSync(home, { recursive: true, force: true }) - } -} -process.exit(failed ? 1 : 0) diff --git a/test/smoke/tsconfig.json b/test/smoke/tsconfig.json deleted file mode 100644 index c9f18848c..000000000 --- a/test/smoke/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.test.json", - "compilerOptions": { - "rootDir": "../.." - }, - "include": ["embedder.ts", "run-bundled.ts"], - "exclude": [] -} diff --git a/tsconfig.test.json b/tsconfig.test.json index 0f6265533..79c285891 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -4,6 +4,5 @@ "noEmit": true, "rootDir": "." }, - "include": ["src/**/*.ts", "test/**/*.ts"], - "exclude": ["test/smoke/**"] + "include": ["src/**/*.ts", "test/**/*.ts"] } From 4f3a8ddc40265f1150aa3b14b51e7ea71e01a04e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 01:58:26 +0000 Subject: [PATCH 14/15] ci: the smoke test looks at which seccomp helper is named, and dist is built once On the machine that built the bundle the library still finds the checkout's helper, so a bundle that lost the setting passed. The type check already builds dist/, so the build step after it goes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- .github/workflows/integration-tests.yml | 8 ++------ test/docker-weak-sandbox.test.ts | 2 +- test/smoke/embedder.ts | 5 +++++ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 591d636cf..c3793a72e 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -434,8 +434,8 @@ jobs: if: matrix.os != 'windows' run: npm ci - # `npm run build` compiles src only. This is the one step that checks - # the types of test/, which bun strips at runtime. + # Builds dist/ as `npm run build` does, which compiles src only. This is + # the one step that checks the types of test/, which bun strips at runtime. - name: Typecheck if: matrix.os != 'windows' run: npm run typecheck @@ -454,10 +454,6 @@ jobs: if: matrix.os != 'windows' run: npm run build:java-agent - - name: Build project - if: matrix.os != 'windows' - run: npm run build - - name: Run tests if: matrix.os != 'windows' run: npm test diff --git a/test/docker-weak-sandbox.test.ts b/test/docker-weak-sandbox.test.ts index 2dbd49595..d28229184 100644 --- a/test/docker-weak-sandbox.test.ts +++ b/test/docker-weak-sandbox.test.ts @@ -5,7 +5,7 @@ * Gated on SRT_E2E_DOCKER so `npm test` on the host jobs skips it. CI runs it * twice in one container — once holding CAP_SYS_ADMIN, once under * `capsh --drop=cap_sys_admin` — because a uid-0 caller is what the - * capability drop exists for. + * capability drop exists for. Then the same in alpine:3.22, for musl. * * Invoked by CI via: * docker run --rm \ diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts index 408cd0c85..bf5537686 100644 --- a/test/smoke/embedder.ts +++ b/test/smoke/embedder.ts @@ -114,6 +114,11 @@ async function main(): Promise { 'exit 7', ].join('; '), ) + check( + 'the seccomp helper it was told of is the one the command starts', + process.platform !== 'linux' || multicall || wrapped.includes(applyPath!), + wrapped, + ) const result = spawnSync(wrapped, { shell: true, encoding: 'utf8', From 503075e742ec0313db288537c0590f3360cae644 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 02:13:43 +0000 Subject: [PATCH 15/15] ci: the step that builds dist says so Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01FydxqUESqxwSCVFnhk3gfv Co-authored-by: ronleizrowice --- .github/workflows/integration-tests.yml | 2 +- test/smoke/embedder.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index c3793a72e..2992c6378 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -436,7 +436,7 @@ jobs: # Builds dist/ as `npm run build` does, which compiles src only. This is # the one step that checks the types of test/, which bun strips at runtime. - - name: Typecheck + - name: Build and typecheck if: matrix.os != 'windows' run: npm run typecheck diff --git a/test/smoke/embedder.ts b/test/smoke/embedder.ts index bf5537686..147b6f507 100644 --- a/test/smoke/embedder.ts +++ b/test/smoke/embedder.ts @@ -116,7 +116,9 @@ async function main(): Promise { ) check( 'the seccomp helper it was told of is the one the command starts', - process.platform !== 'linux' || multicall || wrapped.includes(applyPath!), + process.platform !== 'linux' || + multicall || + (applyPath !== undefined && wrapped.includes(applyPath)), wrapped, ) const result = spawnSync(wrapped, {