Files
ycode/scripts/sandbox-process-regression.ts
T

95 lines
8.8 KiB
TypeScript

import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { join, resolve } from 'node:path'
import { configureSandbox } from '../src/main/sandbox/configuration'
import { importSandboxToolchain } from '../src/main/sandbox/toolchains'
import { executeSandboxCommand, sandboxStatus } from '../src/main/sandbox/service'
await fs.mkdir(resolve('.tmp'), { recursive: true })
const fixture = await fs.mkdtemp(resolve('.tmp/sandbox-process-')), workspace = join(fixture, 'workspace')
await fs.mkdir(workspace)
const outside = join(fixture, 'outside.txt'); await fs.writeFile(outside, 'OUTSIDE_FIXTURE')
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: resolve('resources/sandbox/win32-x64') })
const checks: Array<{ label: string; pass: boolean }> = []
function check(label: string, condition: unknown): void { checks.push({ label, pass: !!condition }); assert.ok(condition, label); console.log(`PASS ${label}`) }
const context = { workspaceRoot: workspace, runtimes: ['node'], timeoutMs: 10_000 }
const source = String.raw`
const assert=require('node:assert/strict'),fs=require('node:fs'),os=require('node:os'),path=require('node:path'),cp=require('node:child_process');
const labels=[];function check(label,fn){fn();labels.push(label);console.log('PROCESS_CHECK '+label)}
const scratch=fs.mkdtempSync(path.join(os.tmpdir(),'node-runtime-'));
try{
check('private-temp',()=>{fs.writeFileSync(path.join(scratch,'x.txt'),'private');assert.equal(fs.readFileSync(path.join(scratch,'x.txt'),'utf8'),'private');fs.unlinkSync(path.join(scratch,'x.txt'))});
check('binary-stdout-stderr',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write(Buffer.from([0,255,1,2]));process.stderr.write("diagnostic")']);assert.equal(r.status,0);assert.deepEqual(r.stdout,Buffer.from([0,255,1,2]));assert.equal(r.stderr.toString(),'diagnostic')});
check('stdin',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdin.on("data",x=>process.stdout.write(x))'],{input:Buffer.from([0,255,19])});assert.equal(r.status,0);assert.deepEqual(r.stdout,Buffer.from([0,255,19]))});
check('cwd-and-env',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write(process.cwd()+"|"+process.env.YCODE_FIXTURE)'],{cwd:scratch,env:{...process.env,YCODE_FIXTURE:'private-value'},encoding:'utf8'});assert.equal(r.status,0);assert.equal(r.stdout,scratch+'|private-value')});
check('exec-file',()=>assert.equal(cp.execFileSync(process.execPath,['-e','process.stdout.write("file-result")'],{encoding:'utf8'}),'file-result'));
check('exec-shell',()=>assert.equal(cp.execSync('echo shell-result',{encoding:'utf8'}).trim(),'shell-result'));
check('omitted-args-options',()=>assert.equal(cp.execFileSync(process.execPath,undefined,{input:'process.stdout.write("stdin-script")',encoding:'utf8'}),'stdin-script'));
check('nonzero-status',()=>{assert.throws(()=>cp.execFileSync(process.execPath,['-e','process.stderr.write("expected-error");process.exit(7)'],{encoding:'utf8'}),e=>e.status===7&&e.stderr==='expected-error')});
check('missing-executable',()=>{const r=cp.spawnSync('definitely-missing-ycode-program.exe');assert.equal(r.error.code,'ENOENT');assert.equal(r.status,null)});
check('timeout',()=>{const r=cp.spawnSync(process.execPath,['-e','setTimeout(()=>console.log("late"),3000)'],{timeout:150,encoding:'utf8'});assert.equal(r.error.code,'ETIMEDOUT');assert.ok(!r.stdout.includes('late'))});
check('max-buffer',()=>{const r=cp.spawnSync(process.execPath,['-e','process.stdout.write("x".repeat(2048))'],{maxBuffer:128,encoding:'utf8'});assert.equal(r.error.code,'ENOBUFS');assert.equal(r.stdout.length,128)});
check('inherited-stdio',()=>assert.equal(cp.spawnSync(process.execPath,['-e','console.log("INHERITED_STDIO_PASS")'],{stdio:'inherit'}).status,0));
check('esm-exports',()=>{const r=cp.spawnSync(process.execPath,['--require',process.env.YCODE_SANDBOX_PROCESS,'--input-type=module','-e','import {execSync} from "node:child_process";console.log(execSync("echo ESM_CAPTURE_PASS",{encoding:"utf8"}).trim())'],{encoding:'utf8'});assert.equal(r.status,0);assert.ok(r.stdout.includes('ESM_CAPTURE_PASS'))});
check('adapter-readonly',()=>{assert.throws(()=>fs.appendFileSync(process.env.YCODE_SANDBOX_PROCESS,'x'),e=>['EACCES','EPERM'].includes(e.code))});
check('host-boundary',()=>{const r=cp.spawnSync(process.execPath,['-e',__HOST_PROBE__],{encoding:'utf8'});assert.equal(r.status,0);assert.equal(r.stdout,'HOST_DENIED')});
check('scratch-cleanup',()=>assert.deepEqual(fs.readdirSync(os.tmpdir()).filter(name=>name.startsWith('ycode-process-')),[]));
console.log('PROCESS_LABELS '+JSON.stringify(labels));
}finally{fs.rmdirSync(scratch)}
`
try {
await importSandboxToolchain('node', await fs.realpath(process.execPath))
const hostProbe = `try{require('fs').readFileSync(${JSON.stringify(outside)});process.exit(9)}catch(e){if(!['EACCES','EPERM'].includes(e.code))throw e;process.stdout.write('HOST_DENIED')}`
await fs.writeFile(join(workspace, 'process.cjs'), source.replace('__HOST_PROBE__', JSON.stringify(hostProbe)))
const result = await executeSandboxCommand('node --require "%YCODE_SANDBOX_PROCESS%" process.cjs', context)
assert.ok(result.ok, result.content)
const labels = JSON.parse(result.data.stdout.split(/\r?\n/).find(line => line.startsWith('PROCESS_LABELS '))!.slice(15)) as string[]
for (const label of labels) check(`actual AppContainer Node ${label}`, true)
check('process adapter creates no host writeback or network authority', result.data.writeback === 'unchanged' && result.data.profileRemoved && result.data.driveRemoved)
const pythonSource = execFileSync('where.exe', ['python'], { encoding: 'utf8', windowsHide: true }).trim().split(/\r?\n/)[0]
await importSandboxToolchain('python', await fs.realpath(pythonSource))
const outsideDirectory = join(fixture, 'outside-directory')
await fs.writeFile(join(workspace, 'temp.py'), [
'import tempfile, pathlib, subprocess, sys, os, sitecustomize',
'with tempfile.TemporaryDirectory() as directory:',
' path = pathlib.Path(directory) / "data.txt"',
' path.write_text("private-python", encoding="utf-8")',
' assert path.read_text() == "private-python"',
' result = subprocess.run([sys.executable, "-c", "print(43)"], capture_output=True, text=True, timeout=3)',
' assert result.returncode == 0 and result.stdout.strip() == "43"',
' private = pathlib.Path(directory) / "mode700"',
' os.mkdir(private, 0o700)',
' (private / "child.txt").write_text("accessible")',
' assert (private / "child.txt").read_text() == "accessible"',
`try: os.mkdir(${JSON.stringify(outsideDirectory)}, 0o700)`,
'except PermissionError: pass',
'else: raise AssertionError("outside directory unexpectedly created")',
'try: pathlib.Path(sitecustomize.__file__).write_text("tamper")',
'except PermissionError: pass',
'else: raise AssertionError("adapter unexpectedly writable")',
'print("PYTHON_TEMP_CAPTURE_PASS")', ''
].join('\n'))
const python = await executeSandboxCommand('python temp.py', { ...context, runtimes: ['python'] })
assert.ok(python.ok, python.content)
check('actual Python private tempfile and anonymous-pipe subprocess capture work', python.ok && python.data.stdout.includes('PYTHON_TEMP_CAPTURE_PASS') && python.data.writeback === 'unchanged')
check('Python mode700 compatibility retains native host denial and read-only adapter', !await fs.stat(outsideDirectory).catch(() => null))
const components = join(fixture, 'components')
await fs.mkdir(components)
for (const name of ['ycode-sandbox.exe', 'manifest.json', 'node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py']) await fs.copyFile(resolve('resources/sandbox/win32-x64', name), join(components, name))
configureSandbox({ stateRoot: join(fixture, 'state'), resourceDirectory: components })
assert.equal((await sandboxStatus()).state, 'installed')
for (const name of ['node-bridge.cjs', 'node-process.cjs', 'python-sitecustomize.py']) {
const original = await fs.readFile(join(components, name))
await fs.appendFile(join(components, name), 'tamper')
assert.equal((await sandboxStatus()).state, 'unavailable')
await assert.rejects(executeSandboxCommand('echo MUST_NOT_RUN', context), /摘要/)
await fs.writeFile(join(components, name), original)
}
check('a tampered shipped adapter invalidates installed status and fails commands closed', true)
console.log(`SANDBOX_PROCESS_PASS ${checks.length}/${checks.length}`)
} finally {
await fs.writeFile(join(fixture, 'report.json'), JSON.stringify({ fixture, checks, scope: 'Actual AppContainer Node explicit synchronous file-stdio adapter and Python temporary-directory/capture regression. Default Node pipes and asynchronous spawn/fork compatibility remain unverified/unsupported.' }, null, 2) + '\n')
console.log(`SANDBOX_PROCESS_REPORT ${join(fixture, 'report.json')}`)
}