diff --git a/.github/workflows/test-chroot.yml b/.github/workflows/test-chroot.yml index b3ea6e4d9..5d7186e4b 100644 --- a/.github/workflows/test-chroot.yml +++ b/.github/workflows/test-chroot.yml @@ -273,7 +273,7 @@ jobs: name: Test Chroot /proc Filesystem runs-on: ubuntu-latest timeout-minutes: 30 - needs: test-chroot-languages # Run after language tests pass + # No dependency on languages - runs in parallel for faster CI steps: - name: Checkout repository @@ -347,7 +347,7 @@ jobs: name: Test Chroot Edge Cases runs-on: ubuntu-latest timeout-minutes: 30 - needs: test-chroot-languages # Run after language tests pass + # No dependency on languages - runs in parallel for faster CI steps: - name: Checkout repository diff --git a/tests/fixtures/batch-runner.ts b/tests/fixtures/batch-runner.ts new file mode 100644 index 000000000..532c2eab4 --- /dev/null +++ b/tests/fixtures/batch-runner.ts @@ -0,0 +1,117 @@ +/** + * Batch Runner - runs multiple commands in a single AWF container invocation. + * + * Each test that calls runner.runWithSudo() spawns a full Docker container + * lifecycle (~15-25s overhead). This utility batches commands that share the + * same allowDomains config into one invocation, cutting container startups + * from ~73 to ~27 across the chroot test suite. + * + * Usage: + * const results = await runBatch(runner, [ + * { name: 'python_version', command: 'python3 --version' }, + * { name: 'node_version', command: 'node --version' }, + * ], { allowDomains: ['github.com'] }); + * + * // Each test asserts against its own result: + * expect(results.get('python_version').exitCode).toBe(0); + */ + +import { AwfRunner, AwfOptions, AwfResult } from './awf-runner'; + +export interface BatchCommand { + name: string; + command: string; +} + +export interface BatchCommandResult { + stdout: string; + exitCode: number; +} + +export interface BatchResults { + /** Get result for a named command. Throws if name not found. */ + get(name: string): BatchCommandResult; + /** The raw AWF result for the entire batch invocation. */ + overall: AwfResult; +} + +// Delimiter tokens – chosen to be unlikely in real command output +const START = '===BATCH_START:'; +const EXIT = '===BATCH_EXIT:'; +const DELIM_END = '==='; + +/** + * Build a bash script that runs each command in a subshell, capturing its + * exit code and delimiting its output. + */ +function generateScript(commands: BatchCommand[]): string { + return commands.map(cmd => { + // Each command runs in a subshell so failures don't abort the batch. + // stdout and stderr are merged (2>&1) so we capture everything. + // IMPORTANT: capture $? immediately into _EC before echo resets it. + return [ + `echo "${START}${cmd.name}${DELIM_END}"`, + `(${cmd.command}) 2>&1`, + `_EC=$?`, + `echo ""`, + `echo "${EXIT}${cmd.name}:$_EC${DELIM_END}"`, + ].join('; '); + }).join('; '); +} + +/** + * Parse the combined stdout into per-command results. + */ +function parseResults(stdout: string, commands: BatchCommand[]): Map { + const results = new Map(); + + for (const cmd of commands) { + const startToken = `${START}${cmd.name}${DELIM_END}`; + const exitPattern = new RegExp(`${EXIT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}${cmd.name}:(\\d+)${DELIM_END}`); + + const startIdx = stdout.indexOf(startToken); + const exitMatch = stdout.match(exitPattern); + + if (startIdx === -1 || !exitMatch) { + // Command output not found – likely the batch was killed early + results.set(cmd.name, { stdout: '', exitCode: -1 }); + continue; + } + + const contentStart = startIdx + startToken.length; + const contentEnd = stdout.indexOf(exitMatch[0], contentStart) - 1; // -1 for the blank line + const cmdStdout = stdout.slice(contentStart, contentEnd).trim(); + const exitCode = parseInt(exitMatch[1], 10); + + results.set(cmd.name, { stdout: cmdStdout, exitCode }); + } + + return results; +} + +/** + * Run multiple commands in a single AWF container invocation. + * + * All commands share the same AwfOptions (allowDomains, timeout, etc.). + * Individual command results are parsed from delimited output. + */ +export async function runBatch( + runner: AwfRunner, + commands: BatchCommand[], + options: AwfOptions, +): Promise { + const script = generateScript(commands); + const result = await runner.runWithSudo(script, options); + const parsed = parseResults(result.stdout, commands); + + return { + get(name: string): BatchCommandResult { + const r = parsed.get(name); + if (!r) { + throw new Error(`Batch command "${name}" not found in results. Available: ${[...parsed.keys()].join(', ')}`); + } + return r; + }, + overall: result, + }; +} diff --git a/tests/integration/chroot-copilot-home.test.ts b/tests/integration/chroot-copilot-home.test.ts index ad0f9f4b3..ee1450748 100644 --- a/tests/integration/chroot-copilot-home.test.ts +++ b/tests/integration/chroot-copilot-home.test.ts @@ -9,6 +9,9 @@ * * The fix mounts ~/.copilot at /host~/.copilot in chroot mode to enable * write access while maintaining security (no full HOME mount). + * + * OPTIMIZATION: All 3 tests share the same allowDomains and are batched + * into a single AWF invocation. Reduces 3 invocations to 1. */ /// @@ -16,69 +19,64 @@ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; +import { runBatch, BatchResults } from '../fixtures/batch-runner'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; describe('Chroot Copilot Home Directory Access', () => { let runner: AwfRunner; - let testCopilotDir: string; + let batch: BatchResults; beforeAll(async () => { await cleanup(false); runner = createRunner(); - + // Ensure ~/.copilot exists on the host (as the workflow does) - testCopilotDir = path.join(os.homedir(), '.copilot'); + const testCopilotDir = path.join(os.homedir(), '.copilot'); if (!fs.existsSync(testCopilotDir)) { fs.mkdirSync(testCopilotDir, { recursive: true, mode: 0o755 }); } - }); - - afterAll(async () => { - await cleanup(false); - }); - test('should be able to write to ~/.copilot directory', async () => { - const result = await runner.runWithSudo( - 'mkdir -p ~/.copilot/test && echo "test-content" > ~/.copilot/test/file.txt && cat ~/.copilot/test/file.txt', + batch = await runBatch(runner, [ { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('test-content'); + name: 'write_file', + command: 'mkdir -p ~/.copilot/test && echo "test-content" > ~/.copilot/test/file.txt && cat ~/.copilot/test/file.txt', + }, + { + name: 'nested_dirs', + command: 'mkdir -p ~/.copilot/pkg/linux-x64/0.0.405 && echo "package-extracted" > ~/.copilot/pkg/linux-x64/0.0.405/marker.txt && cat ~/.copilot/pkg/linux-x64/0.0.405/marker.txt', + }, + { + name: 'permissions', + command: 'touch ~/.copilot/write-test && rm ~/.copilot/write-test && echo "write-success"', + }, + ], { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 60000, + }); }, 120000); - test('should be able to create nested directories in ~/.copilot', async () => { - // Simulate what Copilot CLI does: create pkg/linux-x64/VERSION - const result = await runner.runWithSudo( - 'mkdir -p ~/.copilot/pkg/linux-x64/0.0.405 && echo "package-extracted" > ~/.copilot/pkg/linux-x64/0.0.405/marker.txt && cat ~/.copilot/pkg/linux-x64/0.0.405/marker.txt', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); + afterAll(async () => { + await cleanup(false); + }); - expect(result).toSucceed(); - expect(result.stdout).toContain('package-extracted'); - }, 120000); + test('should be able to write to ~/.copilot directory', () => { + const r = batch.get('write_file'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('test-content'); + }); - test('should verify ~/.copilot is writable with correct permissions', async () => { - const result = await runner.runWithSudo( - 'touch ~/.copilot/write-test && rm ~/.copilot/write-test && echo "write-success"', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); + test('should be able to create nested directories in ~/.copilot', () => { + const r = batch.get('nested_dirs'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('package-extracted'); + }); - expect(result).toSucceed(); - expect(result.stdout).toContain('write-success'); - }, 120000); + test('should verify ~/.copilot is writable with correct permissions', () => { + const r = batch.get('permissions'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('write-success'); + }); }); diff --git a/tests/integration/chroot-edge-cases.test.ts b/tests/integration/chroot-edge-cases.test.ts index b21f0d9f1..532aef11a 100644 --- a/tests/integration/chroot-edge-cases.test.ts +++ b/tests/integration/chroot-edge-cases.test.ts @@ -6,6 +6,9 @@ * * NOTE: stdout may contain entrypoint debug logs in addition to command output. * Use toContain() instead of exact matches, or check the last line of output. + * + * OPTIMIZATION: Tests sharing the same allowDomains + AWF options are batched + * into single container invocations, reducing ~19 invocations to ~8. */ /// @@ -13,6 +16,7 @@ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; +import { runBatch, BatchResults } from '../fixtures/batch-runner'; /** * Helper to get the last non-empty line from stdout (skips debug logs) @@ -34,161 +38,166 @@ describe('Chroot Edge Cases', () => { await cleanup(false); }); - describe('Working Directory Handling', () => { - test('should respect container-workdir in chroot mode', async () => { - const result = await runner.runWithSudo('pwd', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - containerWorkDir: '/tmp', - }); - - expect(result).toSucceed(); - // The last line should be /tmp (after all the debug output) - expect(getLastLine(result.stdout)).toBe('/tmp'); - }, 120000); - - test('should fall back to home directory if workdir does not exist', async () => { - const result = await runner.runWithSudo('pwd', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - containerWorkDir: '/nonexistent/directory/path', - }); - - expect(result).toSucceed(); - // Should fall back to home directory (starts with /) - const lastLine = getLastLine(result.stdout); - expect(lastLine).toMatch(/^\//); - }, 120000); - }); - - describe('Environment Variables', () => { - test('should preserve PATH including tool cache paths', async () => { - const result = await runner.runWithSudo('echo $PATH', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - // PATH should include standard paths - expect(result.stdout).toContain('/usr/bin'); - expect(result.stdout).toContain('/bin'); - }, 120000); - - test('should have HOME set correctly', async () => { - const result = await runner.runWithSudo('echo $HOME', { + // ---------- Batch: all localhost tests that don't need special AWF options ---------- + describe('General checks (batched)', () => { + let batch: BatchResults; + + beforeAll(async () => { + batch = await runBatch(runner, [ + // Environment Variables + { name: 'echo_path', command: 'echo $PATH' }, + { name: 'echo_home', command: 'echo $HOME' }, + // File System Access + { name: 'ls_usr_bin', command: 'ls /usr/bin | head -5' }, + { name: 'cat_passwd', command: 'cat /etc/passwd | head -1' }, + { name: 'tmp_write', command: 'echo "test" > /tmp/chroot-test-$$ && cat /tmp/chroot-test-$$ && rm /tmp/chroot-test-$$' }, + { name: 'docker_socket', command: 'test -S /var/run/docker.sock && echo "has_socket" || echo "no_socket"' }, + // Capability Dropping + { name: 'iptables', command: 'iptables -L 2>&1' }, + { name: 'chroot_cmd', command: 'chroot / /bin/true 2>&1' }, + // Shell Features + { name: 'pipe', command: 'echo "hello world" | grep hello' }, + { name: 'redirect', command: 'echo "redirect test" > /tmp/redirect-test-$$ && cat /tmp/redirect-test-$$ && rm /tmp/redirect-test-$$' }, + { name: 'cmd_subst', command: 'echo "Today is $(date +%Y)"' }, + { name: 'compound', command: 'echo "first" && echo "second" && echo "third"' }, + // User Context + { name: 'id_u', command: 'id -u' }, + { name: 'whoami', command: 'whoami' }, + ], { allowDomains: ['localhost'], logLevel: 'debug', - timeout: 60000, + timeout: 120000, }); - - expect(result).toSucceed(); - // HOME should be a path starting with / - const lastLine = getLastLine(result.stdout); + }, 180000); + + // Environment Variables + test('should preserve PATH including tool cache paths', () => { + const r = batch.get('echo_path'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('/usr/bin'); + expect(r.stdout).toContain('/bin'); + }); + + test('should have HOME set correctly', () => { + const r = batch.get('echo_home'); + expect(r.exitCode).toBe(0); + const lastLine = getLastLine(r.stdout); expect(lastLine).toMatch(/^\//); - }, 120000); + }); // Note: Custom environment variables via --env may not pass through to chroot mode // because the command runs through a script file. Standard env vars like PATH work. - test.skip('should pass custom environment variables', async () => { - const result = await runner.runWithSudo('echo $MY_CUSTOM_VAR', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - env: { - MY_CUSTOM_VAR: 'test_value_123', - }, - }); + test.skip('should pass custom environment variables', () => { + // Placeholder – would need individual invocation with env option + }); + + // File System Access + test('should have read access to /usr', () => { + expect(batch.get('ls_usr_bin').exitCode).toBe(0); + }); + + test('should have read access to /etc', () => { + const r = batch.get('cat_passwd'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('root'); + }); + + test('should have write access to /tmp', () => { + const r = batch.get('tmp_write'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('test'); + }); + + test('should have Docker socket hidden or inaccessible', () => { + const r = batch.get('docker_socket'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('no_socket'); + }); + + // Capability Dropping + test('should not have NET_ADMIN capability', () => { + const r = batch.get('iptables'); + expect(r.exitCode).not.toBe(0); + expect(r.stdout).toMatch(/permission denied|Operation not permitted/i); + }); + + test('should not be able to use chroot command', () => { + expect(batch.get('chroot_cmd').exitCode).not.toBe(0); + }); + + // Shell Features + test('should support shell pipes', () => { + const r = batch.get('pipe'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('hello'); + }); + + test('should support shell redirection', () => { + const r = batch.get('redirect'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('redirect test'); + }); + + test('should support command substitution', () => { + const r = batch.get('cmd_subst'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/Today is \d{4}/); + }); + + test('should support compound commands', () => { + const r = batch.get('compound'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('first'); + expect(r.stdout).toContain('second'); + expect(r.stdout).toContain('third'); + }); + + // User Context + test('should run as non-root user', () => { + const r = batch.get('id_u'); + expect(r.exitCode).toBe(0); + const lastLine = getLastLine(r.stdout); + const uid = parseInt(lastLine); + expect(uid).not.toBe(0); + }); - expect(result).toSucceed(); - expect(result.stdout).toContain('test_value_123'); - }, 120000); + test('should have username set', () => { + const r = batch.get('whoami'); + expect(r.exitCode).toBe(0); + const lastLine = getLastLine(r.stdout); + expect(lastLine).not.toBe('root'); + }); }); - describe('File System Access', () => { - test('should have read access to /usr', async () => { - const result = await runner.runWithSudo('ls /usr/bin | head -5', { + // ---------- Individual: Working directory tests (different containerWorkDir options) ---------- + describe('Working Directory Handling', () => { + test('should respect container-workdir in chroot mode', async () => { + const result = await runner.runWithSudo('pwd', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, + containerWorkDir: '/tmp', }); expect(result).toSucceed(); + expect(getLastLine(result.stdout)).toBe('/tmp'); }, 120000); - test('should have read access to /etc', async () => { - // /etc/hostname might not exist in all environments, check /etc/passwd instead - const result = await runner.runWithSudo('cat /etc/passwd | head -1', { + test('should fall back to home directory if workdir does not exist', async () => { + const result = await runner.runWithSudo('pwd', { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, + containerWorkDir: '/nonexistent/directory/path', }); expect(result).toSucceed(); - // passwd file should have root entry - expect(result.stdout).toContain('root'); - }, 120000); - - test('should have write access to /tmp', async () => { - const result = await runner.runWithSudo( - 'echo "test" > /tmp/chroot-test-$$ && cat /tmp/chroot-test-$$ && rm /tmp/chroot-test-$$', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('test'); - }, 120000); - - test('should have Docker socket hidden or inaccessible', async () => { - // Docker socket should be hidden (mounted to /dev/null) or not exist - const result = await runner.runWithSudo( - 'test -S /var/run/docker.sock && echo "has_socket" || echo "no_socket"', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - // The docker socket should NOT be a socket (it's /dev/null or doesn't exist) - expect(result.stdout).toContain('no_socket'); - }, 120000); - }); - - describe('Capability Dropping', () => { - test('should not have NET_ADMIN capability', async () => { - // Try to run iptables - should fail without NET_ADMIN - const result = await runner.runWithSudo('iptables -L 2>&1', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); - - // Should fail due to lack of permissions - expect(result).toFail(); - expect(result.stdout + result.stderr).toMatch(/permission denied|Operation not permitted/i); - }, 120000); - - test('should not be able to use chroot command', async () => { - // Should not be able to chroot again (capability dropped) - const result = await runner.runWithSudo('chroot / /bin/true 2>&1', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); - - // Should fail due to lack of CAP_SYS_CHROOT - expect(result).toFail(); + const lastLine = getLastLine(result.stdout); + expect(lastLine).toMatch(/^\//); }, 120000); }); + // ---------- Individual: Exit code propagation (tests AWF process exit code) ---------- describe('Exit Code Propagation', () => { test('should propagate exit code 0', async () => { const result = await runner.runWithSudo('exit 0', { @@ -231,6 +240,7 @@ describe('Chroot Edge Cases', () => { }, 120000); }); + // ---------- Individual: Network tests (different domains per test) ---------- describe('Network Firewall Enforcement', () => { test('should allow HTTPS to whitelisted domains', async () => { const result = await runner.runWithSudo('curl -s -o /dev/null -w "%{http_code}" https://api.github.com', { @@ -265,84 +275,4 @@ describe('Chroot Edge Cases', () => { expect(result).toFail(); }, 60000); }); - - describe('Shell Features', () => { - test('should support shell pipes', async () => { - const result = await runner.runWithSudo('echo "hello world" | grep hello', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toContain('hello'); - }, 120000); - - test('should support shell redirection', async () => { - const result = await runner.runWithSudo( - 'echo "redirect test" > /tmp/redirect-test-$$ && cat /tmp/redirect-test-$$ && rm /tmp/redirect-test-$$', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('redirect test'); - }, 120000); - - test('should support command substitution', async () => { - const result = await runner.runWithSudo('echo "Today is $(date +%Y)"', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/Today is \d{4}/); - }, 120000); - - test('should support compound commands', async () => { - const result = await runner.runWithSudo('echo "first" && echo "second" && echo "third"', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toContain('first'); - expect(result.stdout).toContain('second'); - expect(result.stdout).toContain('third'); - }, 120000); - }); - - describe('User Context', () => { - test('should run as non-root user', async () => { - const result = await runner.runWithSudo('id -u', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - // Should not be root (uid 0) - check last line of output - const lastLine = getLastLine(result.stdout); - const uid = parseInt(lastLine); - expect(uid).not.toBe(0); - }, 120000); - - test('should have username set', async () => { - const result = await runner.runWithSudo('whoami', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - // The last line should be the username, which should not be 'root' - const lastLine = getLastLine(result.stdout); - expect(lastLine).not.toBe('root'); - }, 120000); - }); }); diff --git a/tests/integration/chroot-languages.test.ts b/tests/integration/chroot-languages.test.ts index 08011e5db..60cfeca96 100644 --- a/tests/integration/chroot-languages.test.ts +++ b/tests/integration/chroot-languages.test.ts @@ -7,6 +7,10 @@ * * IMPORTANT: These tests require the corresponding languages to be installed * on the host system (GitHub Actions runners have Python, Node, Go pre-installed). + * + * OPTIMIZATION: Quick version checks are batched into a single AWF container + * invocation per domain group, reducing container startup overhead from ~20 + * invocations down to ~4. */ /// @@ -14,6 +18,7 @@ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; +import { runBatch, BatchResults } from '../fixtures/batch-runner'; describe('Chroot Language Support', () => { let runner: AwfRunner; @@ -27,161 +32,152 @@ describe('Chroot Language Support', () => { await cleanup(false); }); - describe('Python', () => { - test('should execute Python from host via chroot', async () => { - const result = await runner.runWithSudo('python3 --version', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout + result.stderr).toMatch(/Python 3\.\d+\.\d+/); - }, 120000); - - test('should run Python inline script', async () => { - const result = await runner.runWithSudo( - 'python3 -c "print(2 + 2)"', - { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('4'); - }, 120000); - - test('should access Python standard library modules', async () => { - const result = await runner.runWithSudo( - 'python3 -c "import json, os, sys; print(json.dumps({\'test\': True}))"', - { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('{"test": true}'); - }, 120000); - - test('should have pip available', async () => { - const result = await runner.runWithSudo('pip3 --version', { + // ---------- Batch: quick version/feature checks (single AWF invocation) ---------- + describe('Quick language checks (batched)', () => { + let batch: BatchResults; + + beforeAll(async () => { + batch = await runBatch(runner, [ + // Python + { name: 'python_version', command: 'python3 --version' }, + { name: 'python_inline', command: 'python3 -c "print(2 + 2)"' }, + { name: 'python_stdlib', command: "python3 -c \"import json, os, sys; print(json.dumps({'test': True}))\"" }, + { name: 'pip_version', command: 'pip3 --version' }, + // Node.js + { name: 'node_version', command: 'node --version' }, + { name: 'node_inline', command: 'node -e "console.log(2 + 2)"' }, + { name: 'node_modules', command: "node -e \"const os = require('os'); console.log(os.platform())\"" }, + { name: 'npm_version', command: 'npm --version' }, + { name: 'npx_version', command: 'npx --version' }, + // Go + { name: 'go_version', command: 'go version' }, + { name: 'go_env', command: 'go env GOVERSION' }, + // Java (version only – compile tests are separate) + { name: 'java_version', command: 'java --version 2>&1 || java -version 2>&1' }, + // .NET (version/info only – compile tests are separate) + { name: 'dotnet_version', command: 'dotnet --version' }, + { name: 'dotnet_info', command: 'dotnet --info 2>&1 | head -30' }, + // Basic System Binaries + { name: 'unix_utils', command: 'which bash && which ls && which cat' }, + { name: 'git_version', command: 'git --version' }, + { name: 'curl_version', command: 'curl --version' }, + ], { allowDomains: ['github.com'], logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout + result.stderr).toMatch(/pip \d+\.\d+/); - }, 120000); - }); - - describe('Node.js', () => { - test('should execute Node.js from host via chroot', async () => { - const result = await runner.runWithSudo('node --version', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/v\d+\.\d+\.\d+/); - }, 120000); - - test('should run Node.js inline script', async () => { - const result = await runner.runWithSudo( - 'node -e "console.log(2 + 2)"', - { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('4'); - }, 120000); - - test('should access Node.js built-in modules', async () => { - const result = await runner.runWithSudo( - 'node -e "const os = require(\'os\'); console.log(os.platform())"', - { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toContain('linux'); - }, 120000); - - test('should have npm available', async () => { - const result = await runner.runWithSudo('npm --version', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); - }, 120000); - - test('should have npx available', async () => { - const result = await runner.runWithSudo('npx --version', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); - }, 120000); - }); - - describe('Go', () => { - test('should execute Go from host via chroot', async () => { - const result = await runner.runWithSudo('go version', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/go version go\d+\.\d+/); - }, 120000); - - test('should run Go env command', async () => { - const result = await runner.runWithSudo('go env GOVERSION', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, + timeout: 120000, }); + }, 180000); - expect(result).toSucceed(); - expect(result.stdout).toMatch(/go\d+\.\d+/); - }, 120000); + // Python + test('should execute Python from host via chroot', () => { + const r = batch.get('python_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/Python 3\.\d+\.\d+/); + }); + + test('should run Python inline script', () => { + const r = batch.get('python_inline'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('4'); + }); + + test('should access Python standard library modules', () => { + const r = batch.get('python_stdlib'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('{"test": true}'); + }); + + test('should have pip available', () => { + const r = batch.get('pip_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/pip \d+\.\d+/); + }); + + // Node.js + test('should execute Node.js from host via chroot', () => { + const r = batch.get('node_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/v\d+\.\d+\.\d+/); + }); + + test('should run Node.js inline script', () => { + const r = batch.get('node_inline'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('4'); + }); + + test('should access Node.js built-in modules', () => { + const r = batch.get('node_modules'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('linux'); + }); + + test('should have npm available', () => { + const r = batch.get('npm_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/\d+\.\d+\.\d+/); + }); + + test('should have npx available', () => { + const r = batch.get('npx_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/\d+\.\d+\.\d+/); + }); + + // Go + test('should execute Go from host via chroot', () => { + const r = batch.get('go_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/go version go\d+\.\d+/); + }); + + test('should run Go env command', () => { + const r = batch.get('go_env'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/go\d+\.\d+/); + }); + + // Java version + test('should execute java --version from host via chroot', () => { + const r = batch.get('java_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/openjdk|java|version/i); + }); + + // .NET version/info + test('should execute dotnet --version from host via chroot', () => { + const r = batch.get('dotnet_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/\d+\.\d+\.\d+/); + }); + + test('should show dotnet runtime information', () => { + const r = batch.get('dotnet_info'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/\.NET|SDK|Runtime/i); + }); + + // Basic System Binaries + test('should access standard Unix utilities', () => { + expect(batch.get('unix_utils').exitCode).toBe(0); + }); + + test('should access git from host', () => { + const r = batch.get('git_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/git version \d+\.\d+/); + }); + + test('should access curl from host', () => { + const r = batch.get('curl_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/curl \d+\.\d+/); + }); }); + // ---------- Individual: Java compile tests (longer timeout) ---------- describe('Java', () => { - test('should execute java --version from host via chroot', async () => { - // Validates JVM starts correctly - before procfs fix, JVM would fail - // because /proc/self/exe resolved to bash instead of the java binary - const result = await runner.runWithSudo('java --version 2>&1 || java -version 2>&1', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout + result.stderr).toMatch(/openjdk|java|version/i); - }, 120000); - test('should compile and run Java Hello World', async () => { - // Full javac + java toolchain validation through chroot const result = await runner.runWithSudo( 'TESTDIR=$(mktemp -d) && ' + 'echo \'public class Hello { public static void main(String[] args) { System.out.println("Hello from Java"); } }\' > $TESTDIR/Hello.java && ' + @@ -198,7 +194,6 @@ describe('Chroot Language Support', () => { }, 180000); test('should access Java standard library (java.util)', async () => { - // Validates JVM class loading works beyond trivial hello world const result = await runner.runWithSudo( 'TESTDIR=$(mktemp -d) && ' + 'cat > $TESTDIR/TestUtil.java << \'EOF\'\n' + @@ -225,33 +220,9 @@ describe('Chroot Language Support', () => { }, 180000); }); + // ---------- Individual: .NET compile test (different domains, long timeout) ---------- describe('.NET', () => { - test('should execute dotnet --version from host via chroot', async () => { - // Primary regression test for the /proc/self/exe fix. - // Before the fix, .NET CLR failed with "Cannot execute dotnet when renamed to bash" - const result = await runner.runWithSudo('dotnet --version', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); - }, 120000); - - test('should show dotnet runtime information', async () => { - const result = await runner.runWithSudo('dotnet --info 2>&1 | head -30', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout + result.stderr).toMatch(/\.NET|SDK|Runtime/i); - }, 120000); - test('should create and run a .NET console app', async () => { - // Full toolchain test: project creation, NuGet restore, build, and run const result = await runner.runWithSudo( 'TESTDIR=$(mktemp -d) && cd $TESTDIR && ' + 'dotnet new console -o testapp --no-restore && ' + @@ -270,41 +241,4 @@ describe('Chroot Language Support', () => { } }, 240000); }); - - describe('Basic System Binaries', () => { - test('should access standard Unix utilities', async () => { - const result = await runner.runWithSudo( - 'which bash && which ls && which cat', - { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - }, 120000); - - test('should access git from host', async () => { - const result = await runner.runWithSudo('git --version', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/git version \d+\.\d+/); - }, 120000); - - test('should access curl from host', async () => { - const result = await runner.runWithSudo('curl --version', { - allowDomains: ['github.com'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/curl \d+\.\d+/); - }, 120000); - }); }); diff --git a/tests/integration/chroot-package-managers.test.ts b/tests/integration/chroot-package-managers.test.ts index ee4a5008a..54de5c0f2 100644 --- a/tests/integration/chroot-package-managers.test.ts +++ b/tests/integration/chroot-package-managers.test.ts @@ -7,6 +7,9 @@ * * IMPORTANT: These tests require the corresponding tools to be installed * on the host system. GitHub Actions runners have most of these pre-installed. + * + * OPTIMIZATION: Commands sharing the same allowDomains are batched into + * single AWF invocations. Reduces ~23 invocations to ~12. */ /// @@ -14,6 +17,7 @@ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; +import { runBatch, BatchResults } from '../fixtures/batch-runner'; describe('Chroot Package Manager Support', () => { let runner: AwfRunner; @@ -27,18 +31,34 @@ describe('Chroot Package Manager Support', () => { await cleanup(false); }); + // ---------- pip (Python) ---------- describe('pip (Python)', () => { - test('should list installed packages', async () => { - const result = await runner.runWithSudo('pip3 list --format=columns | head -5', { + // Batch: pypi domain tests + let pypiResults: BatchResults; + + beforeAll(async () => { + pypiResults = await runBatch(runner, [ + { name: 'pip_list', command: 'pip3 list --format=columns | head -5' }, + { name: 'pip_index', command: 'pip3 index versions requests 2>&1 | head -3' }, + ], { allowDomains: ['pypi.org', 'files.pythonhosted.org'], logLevel: 'debug', - timeout: 60000, + timeout: 90000, }); + }, 150000); - expect(result).toSucceed(); - expect(result.stdout).toContain('Package'); - }, 120000); + test('should list installed packages', () => { + const r = pypiResults.get('pip_list'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toContain('Package'); + }); + test('should search PyPI with network access', () => { + const r = pypiResults.get('pip_index'); + expect(r.exitCode).toBeLessThanOrEqual(1); + }); + + // Individual: localhost-only test test('should show package info without network', async () => { const result = await runner.runWithSudo('pip3 show pip', { allowDomains: ['localhost'], @@ -49,42 +69,35 @@ describe('Chroot Package Manager Support', () => { expect(result).toSucceed(); expect(result.stdout).toContain('Name: pip'); }, 120000); - - test('should search PyPI with network access', async () => { - const result = await runner.runWithSudo('pip3 index versions requests 2>&1 | head -3', { - allowDomains: ['pypi.org'], - logLevel: 'debug', - timeout: 90000, - }); - - // pip index versions should work or show available versions - // Even if command structure changes, we should get some output - expect(result.exitCode).toBeLessThanOrEqual(1); // May fail if pypi not reachable but should not crash - }, 150000); }); + // ---------- npm (Node.js) ---------- describe('npm (Node.js)', () => { - test('should show npm configuration', async () => { - const result = await runner.runWithSudo('npm config list', { - allowDomains: ['registry.npmjs.org'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - }, 120000); - - test('should view package info from npm registry', async () => { - const result = await runner.runWithSudo('npm view chalk version', { + // Batch: registry domain tests + let npmResults: BatchResults; + + beforeAll(async () => { + npmResults = await runBatch(runner, [ + { name: 'npm_config', command: 'npm config list' }, + { name: 'npm_view', command: 'npm view chalk version' }, + ], { allowDomains: ['registry.npmjs.org'], logLevel: 'debug', timeout: 90000, }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); }, 150000); + test('should show npm configuration', () => { + expect(npmResults.get('npm_config').exitCode).toBe(0); + }); + + test('should view package info from npm registry', () => { + const r = npmResults.get('npm_view'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/\d+\.\d+\.\d+/); + }); + + // Individual: blocking test (different domain) test('should be blocked from npm registry without domain', async () => { const result = await runner.runWithSudo('npm view chalk version 2>&1', { allowDomains: ['localhost'], @@ -92,23 +105,40 @@ describe('Chroot Package Manager Support', () => { timeout: 60000, }); - // Should fail because registry is not allowed expect(result).toFail(); }, 120000); }); + // ---------- Rust (cargo) ---------- describe('Rust (cargo)', () => { - test('should execute cargo from host via chroot', async () => { - const result = await runner.runWithSudo('cargo --version', { - allowDomains: ['crates.io', 'static.crates.io'], + // Batch: crates.io domain tests + let cargoResults: BatchResults; + + beforeAll(async () => { + cargoResults = await runBatch(runner, [ + { name: 'cargo_version', command: 'cargo --version' }, + { name: 'cargo_search', command: 'cargo search serde --limit 1 2>&1' }, + ], { + allowDomains: ['crates.io', 'static.crates.io', 'index.crates.io'], logLevel: 'debug', - timeout: 60000, + timeout: 120000, }); + }, 180000); - expect(result).toSucceed(); - expect(result.stdout).toMatch(/cargo \d+\.\d+/); - }, 120000); + test('should execute cargo from host via chroot', () => { + const r = cargoResults.get('cargo_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/cargo \d+\.\d+/); + }); + + test('should search crates.io with network access', () => { + const r = cargoResults.get('cargo_search'); + if (r.exitCode === 0) { + expect(r.stdout).toContain('serde'); + } + }); + // Individual: localhost test test('should execute rustc from host via chroot', async () => { const result = await runner.runWithSudo('rustc --version', { allowDomains: ['localhost'], @@ -119,46 +149,38 @@ describe('Chroot Package Manager Support', () => { expect(result).toSucceed(); expect(result.stdout).toMatch(/rustc \d+\.\d+/); }, 120000); - - test('should search crates.io with network access', async () => { - const result = await runner.runWithSudo('cargo search serde --limit 1 2>&1', { - allowDomains: ['crates.io', 'static.crates.io', 'index.crates.io'], - logLevel: 'debug', - timeout: 120000, - }); - - // Should succeed or fail gracefully - the key is it attempts network access - if (result.success) { - expect(result.stdout).toContain('serde'); - } - }, 180000); }); + // ---------- Java (maven) ---------- describe('Java (maven)', () => { - test('should execute java from host via chroot', async () => { - const result = await runner.runWithSudo('java --version 2>&1 || java -version 2>&1', { + // Batch: localhost tests + let javaResults: BatchResults; + + beforeAll(async () => { + javaResults = await runBatch(runner, [ + { name: 'java_version', command: 'java --version 2>&1 || java -version 2>&1' }, + { name: 'javac_version', command: 'javac --version 2>&1 || javac -version 2>&1' }, + ], { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, }); - - expect(result).toSucceed(); - expect(result.stdout + result.stderr).toMatch(/java|openjdk|version/i); }, 120000); - test('should execute javac from host via chroot', async () => { - const result = await runner.runWithSudo('javac --version 2>&1 || javac -version 2>&1', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); + test('should execute java from host via chroot', () => { + const r = javaResults.get('java_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/java|openjdk|version/i); + }); - // javac might not always be available, but Java should be - if (result.success) { - expect(result.stdout + result.stderr).toMatch(/javac|version/i); + test('should execute javac from host via chroot', () => { + const r = javaResults.get('javac_version'); + if (r.exitCode === 0) { + expect(r.stdout).toMatch(/javac|version/i); } - }, 120000); + }); + // Individual: maven (different domain) test('should execute maven from host via chroot', async () => { const result = await runner.runWithSudo('mvn --version 2>&1', { allowDomains: ['repo.maven.apache.org', 'repo1.maven.org'], @@ -166,38 +188,42 @@ describe('Chroot Package Manager Support', () => { timeout: 60000, }); - // Maven might not be installed, that's OK if (result.success) { expect(result.stdout + result.stderr).toMatch(/Apache Maven|mvn/i); } }, 120000); }); + // ---------- .NET (dotnet/nuget) ---------- describe('.NET (dotnet/nuget)', () => { - test('should list installed .NET SDKs (offline)', async () => { - const result = await runner.runWithSudo('dotnet --list-sdks', { + // Batch: localhost tests + let dotnetResults: BatchResults; + + beforeAll(async () => { + dotnetResults = await runBatch(runner, [ + { name: 'list_sdks', command: 'dotnet --list-sdks' }, + { name: 'list_runtimes', command: 'dotnet --list-runtimes' }, + ], { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); }, 120000); - test('should list installed .NET runtimes (offline)', async () => { - const result = await runner.runWithSudo('dotnet --list-runtimes', { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - }); + test('should list installed .NET SDKs (offline)', () => { + const r = dotnetResults.get('list_sdks'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/\d+\.\d+\.\d+/); + }); - expect(result).toSucceed(); - expect(result.stdout).toMatch(/Microsoft\.\w+/); - }, 120000); + test('should list installed .NET runtimes (offline)', () => { + const r = dotnetResults.get('list_runtimes'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/Microsoft\.\w+/); + }); + // Individual: NuGet restore (different domains, long timeout) test('should create and build a .NET project with NuGet restore', async () => { - // Tests NuGet package restore through the firewall const result = await runner.runWithSudo( 'TESTDIR=$(mktemp -d) && cd $TESTDIR && ' + 'dotnet new console -o buildtest --no-restore && ' + @@ -215,9 +241,8 @@ describe('Chroot Package Manager Support', () => { } }, 240000); + // Individual: blocking test (localhost only) test('should be blocked from NuGet without domain whitelisting', async () => { - // A bare 'dotnet restore' on a default console project may succeed from - // the local SDK cache. Adding an external package forces a network fetch. const result = await runner.runWithSudo( 'TESTDIR=$(mktemp -d) && cd $TESTDIR && ' + 'dotnet new console -o blocktest --no-restore 2>&1 && ' + @@ -232,71 +257,73 @@ describe('Chroot Package Manager Support', () => { } ); - // dotnet restore should fail because NuGet API is not allowed expect(result).toFail(); }, 150000); }); + // ---------- Ruby (gem/bundler) ---------- describe('Ruby (gem/bundler)', () => { - test('should execute ruby from host via chroot', async () => { - const result = await runner.runWithSudo('ruby --version', { + // Batch: localhost tests + let rubyLocalResults: BatchResults; + + beforeAll(async () => { + rubyLocalResults = await runBatch(runner, [ + { name: 'ruby_version', command: 'ruby --version' }, + { name: 'gem_list', command: 'gem list --local | head -5' }, + ], { allowDomains: ['localhost'], logLevel: 'debug', timeout: 60000, }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/ruby \d+\.\d+/); }, 120000); - test('should execute gem from host via chroot', async () => { - const result = await runner.runWithSudo('gem --version', { - allowDomains: ['rubygems.org'], - logLevel: 'debug', - timeout: 60000, - }); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/\d+\.\d+/); - }, 120000); - - test('should list installed gems', async () => { - const result = await runner.runWithSudo('gem list --local | head -5', { - allowDomains: ['localhost'], + test('should execute ruby from host via chroot', () => { + const r = rubyLocalResults.get('ruby_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/ruby \d+\.\d+/); + }); + + test('should list installed gems', () => { + expect(rubyLocalResults.get('gem_list').exitCode).toBe(0); + }); + + // Batch: rubygems.org domain tests + let rubyNetResults: BatchResults; + + beforeAll(async () => { + rubyNetResults = await runBatch(runner, [ + { name: 'gem_version', command: 'gem --version' }, + { name: 'bundler_version', command: 'bundle --version' }, + { name: 'gem_search', command: 'gem search rails --remote --no-verbose 2>&1 | head -3' }, + ], { + allowDomains: ['rubygems.org', 'index.rubygems.org'], logLevel: 'debug', - timeout: 60000, + timeout: 120000, }); + }, 180000); - expect(result).toSucceed(); - }, 120000); + test('should execute gem from host via chroot', () => { + const r = rubyNetResults.get('gem_version'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/\d+\.\d+/); + }); - test('should execute bundler from host via chroot', async () => { - const result = await runner.runWithSudo('bundle --version', { - allowDomains: ['rubygems.org'], - logLevel: 'debug', - timeout: 60000, - }); - - // Bundler might not be installed - if (result.success) { - expect(result.stdout).toMatch(/Bundler version \d+\.\d+/); + test('should execute bundler from host via chroot', () => { + const r = rubyNetResults.get('bundler_version'); + if (r.exitCode === 0) { + expect(r.stdout).toMatch(/Bundler version \d+\.\d+/); } - }, 120000); - - test('should search rubygems with network access', async () => { - const result = await runner.runWithSudo('gem search rails --remote --no-verbose 2>&1 | head -3', { - allowDomains: ['rubygems.org', 'index.rubygems.org'], - logLevel: 'debug', - timeout: 120000, - }); + }); - // Should attempt network access - if (result.success) { - expect(result.stdout).toContain('rails'); + test('should search rubygems with network access', () => { + const r = rubyNetResults.get('gem_search'); + if (r.exitCode === 0) { + expect(r.stdout).toContain('rails'); } - }, 180000); + }); }); + // ---------- Go modules ---------- describe('Go modules', () => { test('should show go env', async () => { const result = await runner.runWithSudo('go env GOPATH GOPROXY', { @@ -309,7 +336,6 @@ describe('Chroot Package Manager Support', () => { }, 120000); test('should list go modules (no network needed for empty list)', async () => { - // Create a temp dir and check go mod functionality const result = await runner.runWithSudo( 'cd /tmp && mkdir -p gotest && cd gotest && go mod init test 2>&1 && go mod tidy 2>&1 && cat go.mod', { diff --git a/tests/integration/chroot-procfs.test.ts b/tests/integration/chroot-procfs.test.ts index e7fd8b488..11aa7b4b4 100644 --- a/tests/integration/chroot-procfs.test.ts +++ b/tests/integration/chroot-procfs.test.ts @@ -10,6 +10,9 @@ * - .NET CLR fails with "Cannot execute dotnet when renamed to bash" * - JVM misreads /proc/self/exe and /proc/cpuinfo * - Rustup proxy binaries appear as bash + * + * OPTIMIZATION: Quick /proc checks are batched into a single AWF invocation, + * and both Java /proc tests share one invocation. Reduces ~8 invocations to 2. */ /// @@ -17,6 +20,7 @@ import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; import { createRunner, AwfRunner } from '../fixtures/awf-runner'; import { cleanup } from '../fixtures/cleanup'; +import { runBatch, BatchResults } from '../fixtures/batch-runner'; describe('Chroot /proc Filesystem Correctness', () => { let runner: AwfRunner; @@ -30,154 +34,128 @@ describe('Chroot /proc Filesystem Correctness', () => { await cleanup(false); }); - describe('/proc/self/exe resolution', () => { - test('should resolve /proc/self/exe to a real path', async () => { - const result = await runner.runWithSudo( - 'readlink /proc/self/exe', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - // Should contain an absolute path (stdout may include debug log lines) - expect(result.stdout).toMatch(/\/usr\/bin\/|\/bin\/|\/usr\/sbin\//); - }, 120000); - - test('should resolve differently for different binaries', async () => { - // The key property of the dynamic procfs mount: each process sees - // its own /proc/self/exe. With the old static bind mount, all - // processes would see the parent bash process. - const result = await runner.runWithSudo( - 'bash -c "readlink /proc/self/exe" && python3 -c "import os; print(os.readlink(\'/proc/self/exe\'))"', + // ---------- Batch 1: quick /proc checks (single AWF invocation) ---------- + describe('/proc checks (batched)', () => { + let batch: BatchResults; + + beforeAll(async () => { + batch = await runBatch(runner, [ + { name: 'readlink_exe', command: 'readlink /proc/self/exe' }, { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); + name: 'diff_binaries', + command: 'bash -c "readlink /proc/self/exe" && python3 -c "import os; print(os.readlink(\'/proc/self/exe\'))"', + }, + { name: 'cpuinfo', command: 'cat /proc/cpuinfo | head -10' }, + { name: 'meminfo', command: 'cat /proc/meminfo | head -5' }, + { name: 'self_status', command: 'cat /proc/self/status | head -5' }, + ], { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 120000, + }); + }, 180000); + + test('should resolve /proc/self/exe to a real path', () => { + const r = batch.get('readlink_exe'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/\/usr\/bin\/|\/bin\/|\/usr\/sbin\//); + }); - expect(result).toSucceed(); - const lines = result.stdout.trim().split('\n').filter(l => l.startsWith('/')); - // bash and python should resolve to different binaries + test('should resolve differently for different binaries', () => { + const r = batch.get('diff_binaries'); + expect(r.exitCode).toBe(0); + const lines = r.stdout.trim().split('\n').filter(l => l.startsWith('/')); if (lines.length >= 2) { expect(lines[0]).not.toEqual(lines[lines.length - 1]); } - }, 120000); + }); + + test('should have /proc/cpuinfo accessible', () => { + const r = batch.get('cpuinfo'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/processor|model name|cpu|vendor/i); + }); + + test('should have /proc/meminfo accessible', () => { + const r = batch.get('meminfo'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/MemTotal/); + }); + + test('should have /proc/self/status accessible', () => { + const r = batch.get('self_status'); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/Name:/); + }); }); - describe('/proc filesystem entries', () => { - test('should have /proc/cpuinfo accessible', async () => { - // JVM reads /proc/cpuinfo for hardware detection - const result = await runner.runWithSudo( - 'cat /proc/cpuinfo | head -10', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/processor|model name|cpu|vendor/i); - }, 120000); - - test('should have /proc/meminfo accessible', async () => { - // JVM uses /proc/meminfo for memory detection and heap sizing - const result = await runner.runWithSudo( - 'cat /proc/meminfo | head -5', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/MemTotal/); - }, 120000); + // ---------- Batch 2: Java /proc tests (single AWF invocation with both Java programs) ---------- + describe('Java /proc/self/exe verification (batched)', () => { + let batch: BatchResults; - test('should have /proc/self/status accessible', async () => { - const result = await runner.runWithSudo( - 'cat /proc/self/status | head -5', + beforeAll(async () => { + batch = await runBatch(runner, [ { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 60000, - } - ); - - expect(result).toSucceed(); - expect(result.stdout).toMatch(/Name:/); - }, 120000); - }); - - describe('Java /proc/self/exe verification', () => { - test('should read /proc/self/exe as java binary from JVM code', async () => { - // Java program directly reads /proc/self/exe and verifies it - // contains "java" not "bash" - the exact bug the procfs fix addresses - const result = await runner.runWithSudo( - 'TESTDIR=$(mktemp -d) && ' + - 'cat > $TESTDIR/ProcSelf.java << \'EOF\'\n' + - 'import java.nio.file.Files;\n' + - 'import java.nio.file.Paths;\n' + - 'public class ProcSelf {\n' + - ' public static void main(String[] args) throws Exception {\n' + - ' String exe = Files.readSymbolicLink(Paths.get("/proc/self/exe")).toString();\n' + - ' System.out.println("proc_self_exe=" + exe);\n' + - ' if (exe.contains("java")) {\n' + - ' System.out.println("CORRECT: /proc/self/exe points to java");\n' + - ' } else {\n' + - ' System.out.println("UNEXPECTED: /proc/self/exe points to " + exe);\n' + - ' }\n' + - ' }\n' + - '}\n' + - 'EOF\n' + - 'cd $TESTDIR && javac ProcSelf.java && java ProcSelf && rm -rf $TESTDIR', + name: 'java_proc_self', + command: + 'TESTDIR=$(mktemp -d) && ' + + "cat > $TESTDIR/ProcSelf.java << 'JAVAEOF'\n" + + 'import java.nio.file.Files;\n' + + 'import java.nio.file.Paths;\n' + + 'public class ProcSelf {\n' + + ' public static void main(String[] args) throws Exception {\n' + + ' String exe = Files.readSymbolicLink(Paths.get("/proc/self/exe")).toString();\n' + + ' System.out.println("proc_self_exe=" + exe);\n' + + ' if (exe.contains("java")) {\n' + + ' System.out.println("CORRECT: /proc/self/exe points to java");\n' + + ' } else {\n' + + ' System.out.println("UNEXPECTED: /proc/self/exe points to " + exe);\n' + + ' }\n' + + ' }\n' + + '}\n' + + 'JAVAEOF\n' + + 'cd $TESTDIR && javac ProcSelf.java && java ProcSelf && rm -rf $TESTDIR', + }, { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 120000, - } - ); - - if (result.success) { - expect(result.stdout).toContain('proc_self_exe='); - expect(result.stdout).toContain('CORRECT: /proc/self/exe points to java'); + name: 'java_processors', + command: + 'TESTDIR=$(mktemp -d) && ' + + "cat > $TESTDIR/MemInfo.java << 'JAVAEOF'\n" + + 'public class MemInfo {\n' + + ' public static void main(String[] args) {\n' + + ' Runtime rt = Runtime.getRuntime();\n' + + ' System.out.println("availableProcessors=" + rt.availableProcessors());\n' + + ' System.out.println("maxMemory=" + rt.maxMemory());\n' + + ' }\n' + + '}\n' + + 'JAVAEOF\n' + + 'cd $TESTDIR && javac MemInfo.java && java MemInfo && rm -rf $TESTDIR', + }, + ], { + allowDomains: ['localhost'], + logLevel: 'debug', + timeout: 180000, + }); + }, 240000); + + test('should read /proc/self/exe as java binary from JVM code', () => { + const r = batch.get('java_proc_self'); + if (r.exitCode === 0) { + expect(r.stdout).toContain('proc_self_exe='); + expect(r.stdout).toContain('CORRECT: /proc/self/exe points to java'); } - }, 180000); - - test('should report correct available processors from JVM', async () => { - // JVM Runtime.availableProcessors() uses /proc/cpuinfo internally - const result = await runner.runWithSudo( - 'TESTDIR=$(mktemp -d) && ' + - 'cat > $TESTDIR/MemInfo.java << \'EOF\'\n' + - 'public class MemInfo {\n' + - ' public static void main(String[] args) {\n' + - ' Runtime rt = Runtime.getRuntime();\n' + - ' System.out.println("availableProcessors=" + rt.availableProcessors());\n' + - ' System.out.println("maxMemory=" + rt.maxMemory());\n' + - ' }\n' + - '}\n' + - 'EOF\n' + - 'cd $TESTDIR && javac MemInfo.java && java MemInfo && rm -rf $TESTDIR', - { - allowDomains: ['localhost'], - logLevel: 'debug', - timeout: 120000, - } - ); - - if (result.success) { - expect(result.stdout).toMatch(/availableProcessors=\d+/); - expect(result.stdout).toMatch(/maxMemory=\d+/); - const match = result.stdout.match(/availableProcessors=(\d+)/); + }); + + test('should report correct available processors from JVM', () => { + const r = batch.get('java_processors'); + if (r.exitCode === 0) { + expect(r.stdout).toMatch(/availableProcessors=\d+/); + expect(r.stdout).toMatch(/maxMemory=\d+/); + const match = r.stdout.match(/availableProcessors=(\d+)/); if (match) { expect(parseInt(match[1])).toBeGreaterThanOrEqual(1); } } - }, 180000); + }); }); });